Skip to main content

pi/core/
settings.rs

1//! Global and project settings: wire types, migrations, trust-gated merge, and
2//! atomic persistence.
3//!
4//! Ports `.references/pi/packages/coding-agent/src/core/settings-manager.ts`.
5//!
6//! Settings documents are carried as raw [`Map<String, Value>`] inside
7//! [`SettingsManager`] so unknown keys, wrong-typed values, and explicit
8//! `null` survive load → merge → save exactly like TypeScript. Typed
9//! [`Settings`] / nested wire structs are a tolerant view; a known key with
10//! the wrong JSON type reads as `None` in the view while remaining on disk.
11//!
12//! Persistence re-reads under the S0 [`LockGuard`], overlays only modified
13//! top-level (and nested) keys, pretty-prints with two-space indent, and does
14//! not force a trailing newline. A scope with a parse error refuses to save.
15//! Project data is fully gated when untrusted; the refusal error is exact.
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::env;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::sync::Mutex;
22
23use pi_agent::QueueMode;
24use pi_ai::{ModelThinkingLevel, Transport};
25use serde_json::{Map, Value};
26use thiserror::Error;
27use uuid::Uuid;
28
29use super::config::{CONFIG_DIR_NAME, expand_tilde_path, get_agent_dir, resolve_path};
30use super::lockfile::LockGuard;
31use super::trust::DefaultProjectTrust;
32
33/// Default tokens reserved for prompt + response during compaction.
34pub const DEFAULT_COMPACTION_RESERVE_TOKENS: u64 = 16384;
35/// Default recent-message tokens kept after compaction.
36pub const DEFAULT_COMPACTION_KEEP_RECENT_TOKENS: u64 = 20000;
37/// Default tokens reserved for branch-summary generation.
38pub const DEFAULT_BRANCH_SUMMARY_RESERVE_TOKENS: u64 = 16384;
39/// Default automatic-retry attempt count.
40pub const DEFAULT_RETRY_MAX_RETRIES: u64 = 3;
41/// Default automatic-retry base backoff delay in milliseconds.
42pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 2000;
43/// Default maximum server-requested retry delay in milliseconds.
44pub const DEFAULT_PROVIDER_MAX_RETRY_DELAY_MS: u64 = 60000;
45/// Default HTTP header/body idle timeout (`http-dispatcher.ts`).
46pub const DEFAULT_HTTP_IDLE_TIMEOUT_MS: u64 = 300_000;
47/// Default preferred inline image width in terminal cells.
48pub const DEFAULT_IMAGE_WIDTH_CELLS: u64 = 60;
49/// Default maximum visible autocomplete items.
50pub const DEFAULT_AUTOCOMPLETE_MAX_VISIBLE: u64 = 5;
51/// Default markdown code block indent.
52pub const DEFAULT_CODE_BLOCK_INDENT: &str = "  ";
53
54const U64_MAX_F64: f64 = 18_446_744_073_709_551_616.0;
55/// `i64::MIN` as an exact `f64` (exactly `-2^63`).
56const I64_MIN_F64: f64 = -9_223_372_036_854_775_808.0;
57
58const KNOWN_SETTINGS_KEYS: &[&str] = &[
59    "lastChangelogVersion",
60    "defaultProvider",
61    "defaultModel",
62    "defaultThinkingLevel",
63    "transport",
64    "steeringMode",
65    "followUpMode",
66    "theme",
67    "compaction",
68    "branchSummary",
69    "retry",
70    "hideThinkingBlock",
71    "showCacheMissNotices",
72    "externalEditor",
73    "shellPath",
74    "quietStartup",
75    "defaultProjectTrust",
76    "shellCommandPrefix",
77    "npmCommand",
78    "collapseChangelog",
79    "enableInstallTelemetry",
80    "enableAnalytics",
81    "trackingId",
82    "packages",
83    "extensions",
84    "skills",
85    "prompts",
86    "themes",
87    "enableSkillCommands",
88    "terminal",
89    "images",
90    "enabledModels",
91    "doubleEscapeAction",
92    "treeFilterMode",
93    "thinkingBudgets",
94    "editorPaddingX",
95    "outputPad",
96    "autocompleteMaxVisible",
97    "showHardwareCursor",
98    "markdown",
99    "warnings",
100    "sessionDir",
101    "httpProxy",
102    "httpIdleTimeoutMs",
103    "websocketConnectTimeoutMs",
104];
105
106const PACKAGE_SOURCE_FILTER_KEYS: &[&str] = &[
107    "source",
108    "autoload",
109    "extensions",
110    "skills",
111    "prompts",
112    "themes",
113];
114
115/// Settings document scope: the global agent file or the project file.
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum SettingsScope {
118    /// `{agentDir}/settings.json`.
119    Global,
120    /// `{cwd}/.pi/settings.json`.
121    Project,
122}
123
124/// One error recorded by [`SettingsManager`], tagged with its scope.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct SettingsError {
127    /// Scope that produced the error.
128    pub scope: SettingsScope,
129    /// Underlying error message.
130    pub message: String,
131}
132
133/// Errors thrown synchronously by [`SettingsManager`] methods.
134#[derive(Debug, Error)]
135pub enum SettingsManagerError {
136    /// Project write attempted while the project is not trusted.
137    #[error("Project is not trusted; refusing to write project settings")]
138    ProjectNotTrusted,
139    /// Invalid value passed to, or stored in, a numeric setting.
140    #[error("Invalid {setting} setting: {value}")]
141    InvalidSetting {
142        /// Wire name of the setting.
143        setting: &'static str,
144        /// JavaScript `String(value)` rendering of the offending value.
145        value: String,
146    },
147}
148
149/// Options for [`SettingsManager::create`] / [`from_storage`] / [`in_memory`].
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
151pub struct SettingsManagerCreateOptions {
152    /// Whether project settings are trusted and loaded. Defaults to `true`.
153    pub project_trusted: bool,
154}
155
156impl Default for SettingsManagerCreateOptions {
157    fn default() -> Self {
158        Self {
159            project_trusted: true,
160        }
161    }
162}
163
164impl SettingsManagerCreateOptions {
165    /// Default options (project trusted).
166    #[must_use]
167    pub fn new() -> Self {
168        Self::default()
169    }
170
171    /// Set whether the project is trusted.
172    #[must_use]
173    pub const fn project_trusted(mut self, project_trusted: bool) -> Self {
174        self.project_trusted = project_trusted;
175        self
176    }
177}
178
179/// Action for double-escape with an empty editor (`doubleEscapeAction`).
180#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
181pub enum DoubleEscapeAction {
182    /// Fork the session.
183    Fork,
184    /// Open the session tree (default).
185    #[default]
186    Tree,
187    /// Do nothing.
188    None,
189}
190
191impl DoubleEscapeAction {
192    /// Wire string used in settings JSON.
193    #[must_use]
194    pub const fn as_str(self) -> &'static str {
195        match self {
196            Self::Fork => "fork",
197            Self::Tree => "tree",
198            Self::None => "none",
199        }
200    }
201
202    fn parse(value: &str) -> Option<Self> {
203        match value {
204            "fork" => Some(Self::Fork),
205            "tree" => Some(Self::Tree),
206            "none" => Some(Self::None),
207            _ => None,
208        }
209    }
210}
211
212/// Default filter when opening `/tree` (`treeFilterMode`).
213#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
214pub enum TreeFilterMode {
215    /// No filtering (default).
216    #[default]
217    Default,
218    /// Hide tool messages.
219    NoTools,
220    /// Show only user messages.
221    UserOnly,
222    /// Show only labeled entries.
223    LabeledOnly,
224    /// Show everything.
225    All,
226}
227
228impl TreeFilterMode {
229    /// Wire string used in settings JSON.
230    #[must_use]
231    pub const fn as_str(self) -> &'static str {
232        match self {
233            Self::Default => "default",
234            Self::NoTools => "no-tools",
235            Self::UserOnly => "user-only",
236            Self::LabeledOnly => "labeled-only",
237            Self::All => "all",
238        }
239    }
240
241    fn parse(value: &str) -> Option<Self> {
242        match value {
243            "default" => Some(Self::Default),
244            "no-tools" => Some(Self::NoTools),
245            "user-only" => Some(Self::UserOnly),
246            "labeled-only" => Some(Self::LabeledOnly),
247            "all" => Some(Self::All),
248            _ => None,
249        }
250    }
251}
252
253/// Horizontal chat-message output padding (`outputPad`, wire `0 | 1`).
254#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
255pub enum OutputPad {
256    /// No padding.
257    Zero,
258    /// One cell of padding (default).
259    #[default]
260    One,
261}
262
263impl OutputPad {
264    /// Wire number used in settings JSON.
265    #[must_use]
266    pub const fn as_u64(self) -> u64 {
267        match self {
268            Self::Zero => 0,
269            Self::One => 1,
270        }
271    }
272}
273
274/// Nested `compaction` settings object.
275#[derive(Clone, Debug, Default, PartialEq)]
276pub struct CompactionSettings {
277    /// Whether automatic compaction is enabled (default: true).
278    pub enabled: Option<bool>,
279    /// Tokens reserved for prompt + LLM response (default: 16384).
280    pub reserve_tokens: Option<u64>,
281    /// Recent-message tokens kept (default: 20000).
282    pub keep_recent_tokens: Option<u64>,
283    /// Unknown nested keys preserved from the raw document.
284    pub extra: Map<String, Value>,
285}
286
287/// Nested `branchSummary` settings object.
288#[derive(Clone, Debug, Default, PartialEq)]
289pub struct BranchSummarySettings {
290    /// Tokens reserved for prompt + LLM response (default: 16384).
291    pub reserve_tokens: Option<u64>,
292    /// Skip the "Summarize branch?" prompt, defaulting to no summary.
293    pub skip_prompt: Option<bool>,
294    /// Unknown nested keys preserved from the raw document.
295    pub extra: Map<String, Value>,
296}
297
298/// Nested `retry.provider` settings object.
299#[derive(Clone, Debug, Default, PartialEq)]
300pub struct ProviderRetrySettings {
301    /// SDK/provider request timeout in milliseconds.
302    pub timeout_ms: Option<u64>,
303    /// SDK/provider retry attempts.
304    pub max_retries: Option<u64>,
305    /// Max server-requested delay before failing (default: 60000).
306    pub max_retry_delay_ms: Option<u64>,
307    /// Unknown nested keys preserved from the raw document.
308    pub extra: Map<String, Value>,
309}
310
311/// Nested `retry` settings object.
312#[derive(Clone, Debug, Default, PartialEq)]
313pub struct RetrySettings {
314    /// Whether automatic retry is enabled (default: true).
315    pub enabled: Option<bool>,
316    /// Retry attempts (default: 3).
317    pub max_retries: Option<u64>,
318    /// Base delay for exponential backoff in milliseconds (default: 2000).
319    pub base_delay_ms: Option<u64>,
320    /// Provider-level retry overrides.
321    pub provider: Option<ProviderRetrySettings>,
322    /// Unknown nested keys preserved from the raw document.
323    pub extra: Map<String, Value>,
324}
325
326/// Nested `terminal` settings object.
327#[derive(Clone, Debug, Default, PartialEq)]
328pub struct TerminalSettings {
329    /// Show inline images when the terminal supports them (default: true).
330    pub show_images: Option<bool>,
331    /// Preferred inline image width in terminal cells (default: 60).
332    pub image_width_cells: Option<u64>,
333    /// Clear empty rows when content shrinks (default: false).
334    pub clear_on_shrink: Option<bool>,
335    /// OSC 9;4 terminal progress indicators (default: false).
336    pub show_terminal_progress: Option<bool>,
337    /// Unknown nested keys preserved from the raw document.
338    pub extra: Map<String, Value>,
339}
340
341/// Nested `images` settings object.
342#[derive(Clone, Debug, Default, PartialEq)]
343pub struct ImageSettings {
344    /// Resize images to 2000x2000 max for model compatibility (default: true).
345    pub auto_resize: Option<bool>,
346    /// Prevent all images from being sent to providers (default: false).
347    pub block_images: Option<bool>,
348    /// Unknown nested keys preserved from the raw document.
349    pub extra: Map<String, Value>,
350}
351
352/// Nested `thinkingBudgets` settings object with custom token budgets.
353#[derive(Clone, Debug, Default, PartialEq)]
354pub struct ThinkingBudgetsSettings {
355    /// Budget for the minimal thinking level.
356    pub minimal: Option<u64>,
357    /// Budget for the low thinking level.
358    pub low: Option<u64>,
359    /// Budget for the medium thinking level.
360    pub medium: Option<u64>,
361    /// Budget for the high thinking level.
362    pub high: Option<u64>,
363    /// Unknown nested keys preserved from the raw document.
364    pub extra: Map<String, Value>,
365}
366
367/// Nested `markdown` settings object.
368#[derive(Clone, Debug, Default, PartialEq)]
369pub struct MarkdownSettings {
370    /// Code block indent (default: two spaces).
371    pub code_block_indent: Option<String>,
372    /// Unknown nested keys preserved from the raw document.
373    pub extra: Map<String, Value>,
374}
375
376/// Nested `warnings` settings object.
377#[derive(Clone, Debug, Default, PartialEq)]
378pub struct WarningSettings {
379    /// Warn about Anthropic extra usage (default: true).
380    pub anthropic_extra_usage: Option<bool>,
381    /// Unknown nested keys preserved from the raw document.
382    pub extra: Map<String, Value>,
383}
384
385/// Package source for npm/git packages (`packages` array elements).
386#[derive(Clone, Debug, PartialEq)]
387pub enum PackageSource {
388    /// Load all resources from the package (string form).
389    Source(String),
390    /// Object form with resource filtering.
391    Filtered(PackageSourceFilter),
392}
393
394/// Object form of [`PackageSource`].
395#[derive(Clone, Debug, Default, PartialEq)]
396pub struct PackageSourceFilter {
397    /// Package source specifier (npm/git/local path).
398    pub source: String,
399    /// When false, only explicit resource patterns are applied.
400    pub autoload: Option<bool>,
401    /// Extension file/directory patterns to load.
402    pub extensions: Option<Vec<String>>,
403    /// Skill file/directory patterns to load.
404    pub skills: Option<Vec<String>>,
405    /// Prompt template path patterns to load.
406    pub prompts: Option<Vec<String>>,
407    /// Theme path patterns to load.
408    pub themes: Option<Vec<String>>,
409    /// Unknown nested keys preserved from the raw document.
410    pub extra: Map<String, Value>,
411}
412
413impl PackageSource {
414    /// Tolerant conversion from a raw JSON value.
415    #[must_use]
416    pub fn from_value(value: &Value) -> Option<Self> {
417        match value {
418            Value::String(source) => Some(Self::Source(source.clone())),
419            Value::Object(map) => {
420                let source = map.get("source").and_then(Value::as_str)?.to_owned();
421                Some(Self::Filtered(PackageSourceFilter {
422                    source,
423                    autoload: map.get("autoload").and_then(Value::as_bool),
424                    extensions: string_array(map.get("extensions")),
425                    skills: string_array(map.get("skills")),
426                    prompts: string_array(map.get("prompts")),
427                    themes: string_array(map.get("themes")),
428                    extra: unknown_fields(map, PACKAGE_SOURCE_FILTER_KEYS),
429                }))
430            }
431            _ => None,
432        }
433    }
434
435    /// Serialize to the raw JSON wire form.
436    #[must_use]
437    pub fn to_value(&self) -> Value {
438        match self {
439            Self::Source(source) => Value::String(source.clone()),
440            Self::Filtered(filter) => {
441                let mut map = filter.extra.clone();
442                map.insert("source".to_owned(), Value::String(filter.source.clone()));
443                insert_opt_bool(&mut map, "autoload", filter.autoload);
444                insert_opt_strings(&mut map, "extensions", filter.extensions.as_deref());
445                insert_opt_strings(&mut map, "skills", filter.skills.as_deref());
446                insert_opt_strings(&mut map, "prompts", filter.prompts.as_deref());
447                insert_opt_strings(&mut map, "themes", filter.themes.as_deref());
448                Value::Object(map)
449            }
450        }
451    }
452}
453
454/// Typed view over a settings document.
455///
456/// Every field mirrors the TypeScript `Settings` interface. Unknown keys are
457/// preserved in [`Self::extra`]; a known key whose stored value has the wrong
458/// JSON type reads as `None` here while remaining untouched on disk.
459#[derive(Clone, Debug, Default, PartialEq)]
460pub struct Settings {
461    /// Last changelog version acknowledged by the user.
462    pub last_changelog_version: Option<String>,
463    /// Default provider identifier.
464    pub default_provider: Option<String>,
465    /// Default model identifier.
466    pub default_model: Option<String>,
467    /// Default thinking level (wire includes `"off"`).
468    pub default_thinking_level: Option<ModelThinkingLevel>,
469    /// Streaming transport preference (default: auto).
470    pub transport: Option<Transport>,
471    /// Steering queue drain mode (default: one-at-a-time).
472    pub steering_mode: Option<QueueMode>,
473    /// Follow-up queue drain mode (default: one-at-a-time).
474    pub follow_up_mode: Option<QueueMode>,
475    /// Theme name or path.
476    pub theme: Option<String>,
477    /// Compaction settings.
478    pub compaction: Option<CompactionSettings>,
479    /// Branch-summary settings.
480    pub branch_summary: Option<BranchSummarySettings>,
481    /// Retry settings.
482    pub retry: Option<RetrySettings>,
483    /// Hide thinking blocks in the transcript (default: false).
484    pub hide_thinking_block: Option<bool>,
485    /// Show prompt-cache-miss transcript notices (default: false).
486    pub show_cache_miss_notices: Option<bool>,
487    /// Command for the Ctrl+G external editor; takes precedence over VISUAL/EDITOR.
488    pub external_editor: Option<String>,
489    /// Custom shell path; supports leading `~` expansion.
490    pub shell_path: Option<String>,
491    /// Suppress startup output (default: false).
492    pub quiet_startup: Option<bool>,
493    /// Default project trust decision; honored from the global file only.
494    pub default_project_trust: Option<DefaultProjectTrust>,
495    /// Prefix prepended to every bash command.
496    pub shell_command_prefix: Option<String>,
497    /// argv-style command used for npm lookup/install operations.
498    pub npm_command: Option<Vec<String>>,
499    /// Show condensed changelog after update (default: false).
500    pub collapse_changelog: Option<bool>,
501    /// Anonymous version/update ping after updates (default: true).
502    pub enable_install_telemetry: Option<bool>,
503    /// Opt-in analytics data sharing (default: false).
504    pub enable_analytics: Option<bool>,
505    /// Analytics tracking identifier, generated on first analytics opt-in.
506    pub tracking_id: Option<String>,
507    /// npm/git package sources.
508    pub packages: Option<Vec<PackageSource>>,
509    /// Local extension file paths or directories.
510    pub extensions: Option<Vec<String>>,
511    /// Local skill file paths or directories.
512    pub skills: Option<Vec<String>>,
513    /// Local prompt template paths or directories.
514    pub prompts: Option<Vec<String>>,
515    /// Local theme file paths or directories.
516    pub themes: Option<Vec<String>>,
517    /// Register skills as `/skill:name` commands (default: true).
518    pub enable_skill_commands: Option<bool>,
519    /// Terminal settings.
520    pub terminal: Option<TerminalSettings>,
521    /// Image settings.
522    pub images: Option<ImageSettings>,
523    /// Model patterns for cycling (same format as `--models`).
524    pub enabled_models: Option<Vec<String>>,
525    /// Double-escape action with empty editor (default: tree).
526    pub double_escape_action: Option<DoubleEscapeAction>,
527    /// Default `/tree` filter.
528    pub tree_filter_mode: Option<TreeFilterMode>,
529    /// Custom token budgets for thinking levels.
530    pub thinking_budgets: Option<ThinkingBudgetsSettings>,
531    /// Horizontal input-editor padding (default: 0).
532    pub editor_padding_x: Option<u64>,
533    /// Horizontal chat-output padding (default: 1).
534    pub output_pad: Option<OutputPad>,
535    /// Max visible autocomplete items (default: 5).
536    pub autocomplete_max_visible: Option<u64>,
537    /// Show the terminal cursor while still positioning it for IME.
538    pub show_hardware_cursor: Option<bool>,
539    /// Markdown rendering settings.
540    pub markdown: Option<MarkdownSettings>,
541    /// Warning toggles.
542    pub warnings: Option<WarningSettings>,
543    /// Custom session storage directory (same format as `--session-dir`).
544    pub session_dir: Option<String>,
545    /// Proxy URL applied as `HTTP_PROXY`/`HTTPS_PROXY` for managed HTTP clients.
546    pub http_proxy: Option<String>,
547    /// HTTP header/body idle timeout in milliseconds; 0 disables.
548    pub http_idle_timeout_ms: Option<u64>,
549    /// WebSocket connect/open handshake timeout in milliseconds; 0 disables.
550    pub websocket_connect_timeout_ms: Option<u64>,
551    /// Unknown top-level keys preserved from the raw document.
552    pub extra: Map<String, Value>,
553}
554
555impl Settings {
556    /// Build the tolerant typed view over a raw settings object.
557    #[must_use]
558    pub fn from_map(map: &Map<String, Value>) -> Self {
559        Self {
560            last_changelog_version: string_field(map, "lastChangelogVersion"),
561            default_provider: string_field(map, "defaultProvider"),
562            default_model: string_field(map, "defaultModel"),
563            default_thinking_level: parse_thinking_level(map.get("defaultThinkingLevel")),
564            transport: parse_transport(map.get("transport")),
565            steering_mode: parse_queue_mode(map.get("steeringMode")),
566            follow_up_mode: parse_queue_mode(map.get("followUpMode")),
567            theme: string_field(map, "theme"),
568            compaction: nested_field(map, "compaction", CompactionSettings::from_map),
569            branch_summary: nested_field(map, "branchSummary", BranchSummarySettings::from_map),
570            retry: nested_field(map, "retry", RetrySettings::from_map),
571            hide_thinking_block: bool_field(map, "hideThinkingBlock"),
572            show_cache_miss_notices: bool_field(map, "showCacheMissNotices"),
573            external_editor: string_field(map, "externalEditor"),
574            shell_path: string_field(map, "shellPath"),
575            quiet_startup: bool_field(map, "quietStartup"),
576            default_project_trust: parse_default_project_trust(map.get("defaultProjectTrust")),
577            shell_command_prefix: string_field(map, "shellCommandPrefix"),
578            npm_command: string_array(map.get("npmCommand")),
579            collapse_changelog: bool_field(map, "collapseChangelog"),
580            enable_install_telemetry: bool_field(map, "enableInstallTelemetry"),
581            enable_analytics: bool_field(map, "enableAnalytics"),
582            tracking_id: string_field(map, "trackingId"),
583            packages: map
584                .get("packages")
585                .and_then(Value::as_array)
586                .map(|items| items.iter().filter_map(PackageSource::from_value).collect()),
587            extensions: string_array(map.get("extensions")),
588            skills: string_array(map.get("skills")),
589            prompts: string_array(map.get("prompts")),
590            themes: string_array(map.get("themes")),
591            enable_skill_commands: bool_field(map, "enableSkillCommands"),
592            terminal: nested_field(map, "terminal", TerminalSettings::from_map),
593            images: nested_field(map, "images", ImageSettings::from_map),
594            enabled_models: string_array(map.get("enabledModels")),
595            double_escape_action: map
596                .get("doubleEscapeAction")
597                .and_then(Value::as_str)
598                .and_then(DoubleEscapeAction::parse),
599            tree_filter_mode: map
600                .get("treeFilterMode")
601                .and_then(Value::as_str)
602                .and_then(TreeFilterMode::parse),
603            thinking_budgets: nested_field(
604                map,
605                "thinkingBudgets",
606                ThinkingBudgetsSettings::from_map,
607            ),
608            editor_padding_x: number_to_u64(map.get("editorPaddingX")),
609            output_pad: parse_output_pad(map.get("outputPad")),
610            autocomplete_max_visible: number_to_u64(map.get("autocompleteMaxVisible")),
611            show_hardware_cursor: bool_field(map, "showHardwareCursor"),
612            markdown: nested_field(map, "markdown", MarkdownSettings::from_map),
613            warnings: nested_field(map, "warnings", WarningSettings::from_map),
614            session_dir: string_field(map, "sessionDir"),
615            http_proxy: string_field(map, "httpProxy"),
616            http_idle_timeout_ms: map.get("httpIdleTimeoutMs").and_then(parse_timeout_ms),
617            websocket_connect_timeout_ms: map
618                .get("websocketConnectTimeoutMs")
619                .and_then(parse_timeout_ms),
620            extra: unknown_fields(map, KNOWN_SETTINGS_KEYS),
621        }
622    }
623
624    /// Serialize the typed view back to a raw settings object.
625    ///
626    /// `extra` keys are written first, then known keys, so a manually built
627    /// `Settings` with a colliding `extra` key keeps the typed field's value.
628    #[must_use]
629    pub fn to_map(&self) -> Map<String, Value> {
630        let mut map = self.extra.clone();
631        self.insert_scalar_fields(&mut map);
632        self.insert_nested_fields(&mut map);
633        self.insert_resource_fields(&mut map);
634        map
635    }
636
637    fn insert_scalar_fields(&self, map: &mut Map<String, Value>) {
638        insert_opt_string(
639            map,
640            "lastChangelogVersion",
641            self.last_changelog_version.as_deref(),
642        );
643        insert_opt_string(map, "defaultProvider", self.default_provider.as_deref());
644        insert_opt_string(map, "defaultModel", self.default_model.as_deref());
645        insert_opt_value(
646            map,
647            "defaultThinkingLevel",
648            self.default_thinking_level.map(thinking_level_value),
649        );
650        insert_opt_value(map, "transport", self.transport.map(transport_value));
651        insert_opt_value(
652            map,
653            "steeringMode",
654            self.steering_mode.map(queue_mode_value),
655        );
656        insert_opt_value(
657            map,
658            "followUpMode",
659            self.follow_up_mode.map(queue_mode_value),
660        );
661        insert_opt_string(map, "theme", self.theme.as_deref());
662        insert_opt_bool(map, "hideThinkingBlock", self.hide_thinking_block);
663        insert_opt_bool(map, "showCacheMissNotices", self.show_cache_miss_notices);
664        insert_opt_string(map, "externalEditor", self.external_editor.as_deref());
665        insert_opt_string(map, "shellPath", self.shell_path.as_deref());
666        insert_opt_bool(map, "quietStartup", self.quiet_startup);
667        insert_opt_value(
668            map,
669            "defaultProjectTrust",
670            self.default_project_trust
671                .map(|trust| Value::String(trust.as_str().to_owned())),
672        );
673        insert_opt_string(
674            map,
675            "shellCommandPrefix",
676            self.shell_command_prefix.as_deref(),
677        );
678        insert_opt_bool(map, "collapseChangelog", self.collapse_changelog);
679        insert_opt_bool(map, "enableInstallTelemetry", self.enable_install_telemetry);
680        insert_opt_bool(map, "enableAnalytics", self.enable_analytics);
681        insert_opt_string(map, "trackingId", self.tracking_id.as_deref());
682        insert_opt_bool(map, "enableSkillCommands", self.enable_skill_commands);
683        insert_opt_value(
684            map,
685            "doubleEscapeAction",
686            self.double_escape_action
687                .map(|action| Value::String(action.as_str().to_owned())),
688        );
689        insert_opt_value(
690            map,
691            "treeFilterMode",
692            self.tree_filter_mode
693                .map(|mode| Value::String(mode.as_str().to_owned())),
694        );
695        insert_opt_u64(map, "editorPaddingX", self.editor_padding_x);
696        insert_opt_value(
697            map,
698            "outputPad",
699            self.output_pad.map(|pad| Value::from(pad.as_u64())),
700        );
701        insert_opt_u64(map, "autocompleteMaxVisible", self.autocomplete_max_visible);
702        insert_opt_bool(map, "showHardwareCursor", self.show_hardware_cursor);
703        insert_opt_string(map, "sessionDir", self.session_dir.as_deref());
704        insert_opt_string(map, "httpProxy", self.http_proxy.as_deref());
705        insert_opt_u64(map, "httpIdleTimeoutMs", self.http_idle_timeout_ms);
706        insert_opt_u64(
707            map,
708            "websocketConnectTimeoutMs",
709            self.websocket_connect_timeout_ms,
710        );
711    }
712
713    fn insert_nested_fields(&self, map: &mut Map<String, Value>) {
714        insert_opt_value(
715            map,
716            "compaction",
717            self.compaction
718                .as_ref()
719                .map(|value| Value::Object(value.to_map())),
720        );
721        insert_opt_value(
722            map,
723            "branchSummary",
724            self.branch_summary
725                .as_ref()
726                .map(|value| Value::Object(value.to_map())),
727        );
728        insert_opt_value(
729            map,
730            "retry",
731            self.retry
732                .as_ref()
733                .map(|value| Value::Object(value.to_map())),
734        );
735        insert_opt_value(
736            map,
737            "terminal",
738            self.terminal
739                .as_ref()
740                .map(|value| Value::Object(value.to_map())),
741        );
742        insert_opt_value(
743            map,
744            "images",
745            self.images
746                .as_ref()
747                .map(|value| Value::Object(value.to_map())),
748        );
749        insert_opt_value(
750            map,
751            "thinkingBudgets",
752            self.thinking_budgets
753                .as_ref()
754                .map(|value| Value::Object(value.to_map())),
755        );
756        insert_opt_value(
757            map,
758            "markdown",
759            self.markdown
760                .as_ref()
761                .map(|value| Value::Object(value.to_map())),
762        );
763        insert_opt_value(
764            map,
765            "warnings",
766            self.warnings
767                .as_ref()
768                .map(|value| Value::Object(value.to_map())),
769        );
770    }
771
772    fn insert_resource_fields(&self, map: &mut Map<String, Value>) {
773        insert_opt_strings(map, "npmCommand", self.npm_command.as_deref());
774        insert_opt_value(
775            map,
776            "packages",
777            self.packages.as_ref().map(|packages| {
778                Value::Array(packages.iter().map(PackageSource::to_value).collect())
779            }),
780        );
781        insert_opt_strings(map, "extensions", self.extensions.as_deref());
782        insert_opt_strings(map, "skills", self.skills.as_deref());
783        insert_opt_strings(map, "prompts", self.prompts.as_deref());
784        insert_opt_strings(map, "themes", self.themes.as_deref());
785        insert_opt_strings(map, "enabledModels", self.enabled_models.as_deref());
786    }
787}
788
789impl CompactionSettings {
790    fn from_map(map: &Map<String, Value>) -> Self {
791        Self {
792            enabled: bool_field(map, "enabled"),
793            reserve_tokens: number_to_u64(map.get("reserveTokens")),
794            keep_recent_tokens: number_to_u64(map.get("keepRecentTokens")),
795            extra: unknown_fields(map, &["enabled", "reserveTokens", "keepRecentTokens"]),
796        }
797    }
798
799    fn to_map(&self) -> Map<String, Value> {
800        let mut map = self.extra.clone();
801        insert_opt_bool(&mut map, "enabled", self.enabled);
802        insert_opt_u64(&mut map, "reserveTokens", self.reserve_tokens);
803        insert_opt_u64(&mut map, "keepRecentTokens", self.keep_recent_tokens);
804        map
805    }
806}
807
808impl BranchSummarySettings {
809    fn from_map(map: &Map<String, Value>) -> Self {
810        Self {
811            reserve_tokens: number_to_u64(map.get("reserveTokens")),
812            skip_prompt: bool_field(map, "skipPrompt"),
813            extra: unknown_fields(map, &["reserveTokens", "skipPrompt"]),
814        }
815    }
816
817    fn to_map(&self) -> Map<String, Value> {
818        let mut map = self.extra.clone();
819        insert_opt_u64(&mut map, "reserveTokens", self.reserve_tokens);
820        insert_opt_bool(&mut map, "skipPrompt", self.skip_prompt);
821        map
822    }
823}
824
825impl ProviderRetrySettings {
826    fn from_map(map: &Map<String, Value>) -> Self {
827        Self {
828            timeout_ms: number_to_u64(map.get("timeoutMs")),
829            max_retries: number_to_u64(map.get("maxRetries")),
830            max_retry_delay_ms: number_to_u64(map.get("maxRetryDelayMs")),
831            extra: unknown_fields(map, &["timeoutMs", "maxRetries", "maxRetryDelayMs"]),
832        }
833    }
834
835    fn to_map(&self) -> Map<String, Value> {
836        let mut map = self.extra.clone();
837        insert_opt_u64(&mut map, "timeoutMs", self.timeout_ms);
838        insert_opt_u64(&mut map, "maxRetries", self.max_retries);
839        insert_opt_u64(&mut map, "maxRetryDelayMs", self.max_retry_delay_ms);
840        map
841    }
842}
843
844impl RetrySettings {
845    fn from_map(map: &Map<String, Value>) -> Self {
846        Self {
847            enabled: bool_field(map, "enabled"),
848            max_retries: number_to_u64(map.get("maxRetries")),
849            base_delay_ms: number_to_u64(map.get("baseDelayMs")),
850            provider: nested_field(map, "provider", ProviderRetrySettings::from_map),
851            extra: unknown_fields(map, &["enabled", "maxRetries", "baseDelayMs", "provider"]),
852        }
853    }
854
855    fn to_map(&self) -> Map<String, Value> {
856        let mut map = self.extra.clone();
857        insert_opt_bool(&mut map, "enabled", self.enabled);
858        insert_opt_u64(&mut map, "maxRetries", self.max_retries);
859        insert_opt_u64(&mut map, "baseDelayMs", self.base_delay_ms);
860        insert_opt_value(
861            &mut map,
862            "provider",
863            self.provider
864                .as_ref()
865                .map(|value| Value::Object(value.to_map())),
866        );
867        map
868    }
869}
870
871impl TerminalSettings {
872    fn from_map(map: &Map<String, Value>) -> Self {
873        Self {
874            show_images: bool_field(map, "showImages"),
875            image_width_cells: number_to_u64(map.get("imageWidthCells")),
876            clear_on_shrink: bool_field(map, "clearOnShrink"),
877            show_terminal_progress: bool_field(map, "showTerminalProgress"),
878            extra: unknown_fields(
879                map,
880                &[
881                    "showImages",
882                    "imageWidthCells",
883                    "clearOnShrink",
884                    "showTerminalProgress",
885                ],
886            ),
887        }
888    }
889
890    fn to_map(&self) -> Map<String, Value> {
891        let mut map = self.extra.clone();
892        insert_opt_bool(&mut map, "showImages", self.show_images);
893        insert_opt_u64(&mut map, "imageWidthCells", self.image_width_cells);
894        insert_opt_bool(&mut map, "clearOnShrink", self.clear_on_shrink);
895        insert_opt_bool(
896            &mut map,
897            "showTerminalProgress",
898            self.show_terminal_progress,
899        );
900        map
901    }
902}
903
904impl ImageSettings {
905    fn from_map(map: &Map<String, Value>) -> Self {
906        Self {
907            auto_resize: bool_field(map, "autoResize"),
908            block_images: bool_field(map, "blockImages"),
909            extra: unknown_fields(map, &["autoResize", "blockImages"]),
910        }
911    }
912
913    fn to_map(&self) -> Map<String, Value> {
914        let mut map = self.extra.clone();
915        insert_opt_bool(&mut map, "autoResize", self.auto_resize);
916        insert_opt_bool(&mut map, "blockImages", self.block_images);
917        map
918    }
919}
920
921impl ThinkingBudgetsSettings {
922    fn from_map(map: &Map<String, Value>) -> Self {
923        Self {
924            minimal: number_to_u64(map.get("minimal")),
925            low: number_to_u64(map.get("low")),
926            medium: number_to_u64(map.get("medium")),
927            high: number_to_u64(map.get("high")),
928            extra: unknown_fields(map, &["minimal", "low", "medium", "high"]),
929        }
930    }
931
932    fn to_map(&self) -> Map<String, Value> {
933        let mut map = self.extra.clone();
934        insert_opt_u64(&mut map, "minimal", self.minimal);
935        insert_opt_u64(&mut map, "low", self.low);
936        insert_opt_u64(&mut map, "medium", self.medium);
937        insert_opt_u64(&mut map, "high", self.high);
938        map
939    }
940}
941
942impl MarkdownSettings {
943    fn from_map(map: &Map<String, Value>) -> Self {
944        Self {
945            code_block_indent: string_field(map, "codeBlockIndent"),
946            extra: unknown_fields(map, &["codeBlockIndent"]),
947        }
948    }
949
950    fn to_map(&self) -> Map<String, Value> {
951        let mut map = self.extra.clone();
952        insert_opt_string(
953            &mut map,
954            "codeBlockIndent",
955            self.code_block_indent.as_deref(),
956        );
957        map
958    }
959}
960
961impl WarningSettings {
962    fn from_map(map: &Map<String, Value>) -> Self {
963        Self {
964            anthropic_extra_usage: bool_field(map, "anthropicExtraUsage"),
965            extra: unknown_fields(map, &["anthropicExtraUsage"]),
966        }
967    }
968
969    fn to_map(&self) -> Map<String, Value> {
970        let mut map = self.extra.clone();
971        insert_opt_bool(&mut map, "anthropicExtraUsage", self.anthropic_extra_usage);
972        map
973    }
974}
975
976/// Fully-resolved compaction configuration.
977#[derive(Clone, Copy, Debug, Eq, PartialEq)]
978pub struct ResolvedCompactionSettings {
979    /// Whether automatic compaction is enabled.
980    pub enabled: bool,
981    /// Tokens reserved for prompt + LLM response.
982    pub reserve_tokens: u64,
983    /// Recent-message tokens kept.
984    pub keep_recent_tokens: u64,
985}
986
987/// Fully-resolved branch-summary configuration.
988#[derive(Clone, Copy, Debug, Eq, PartialEq)]
989pub struct ResolvedBranchSummarySettings {
990    /// Tokens reserved for prompt + LLM response.
991    pub reserve_tokens: u64,
992    /// Whether the "Summarize branch?" prompt is skipped.
993    pub skip_prompt: bool,
994}
995
996/// Fully-resolved retry configuration.
997#[derive(Clone, Copy, Debug, Eq, PartialEq)]
998pub struct ResolvedRetrySettings {
999    /// Whether automatic retry is enabled.
1000    pub enabled: bool,
1001    /// Retry attempts.
1002    pub max_retries: u64,
1003    /// Base delay for exponential backoff in milliseconds.
1004    pub base_delay_ms: u64,
1005}
1006
1007/// Fully-resolved provider retry configuration.
1008#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1009pub struct ResolvedProviderRetrySettings {
1010    /// SDK/provider request timeout in milliseconds.
1011    pub timeout_ms: Option<u64>,
1012    /// SDK/provider retry attempts.
1013    pub max_retries: Option<u64>,
1014    /// Max server-requested delay before failing.
1015    pub max_retry_delay_ms: u64,
1016}
1017
1018/// Storage backend for settings documents.
1019///
1020/// Ports the TypeScript `SettingsStorage` interface: `with_lock` hands the
1021/// current raw document text (or `None` when the file does not exist) to `f`;
1022/// the callback returns the next text to persist, or `Ok(None)` to perform no
1023/// write. A callback `Err` aborts without writing.
1024pub trait SettingsStorage: Send + Sync {
1025    /// Run `f` under the scope's exclusive lock.
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns the lock, read, write, or callback error message.
1030    fn with_lock(
1031        &mut self,
1032        scope: SettingsScope,
1033        f: &mut dyn FnMut(Option<String>) -> Result<Option<String>, String>,
1034    ) -> Result<(), String>;
1035}
1036
1037/// File-backed storage: `{agentDir}/settings.json` and `{cwd}/.pi/settings.json`.
1038///
1039/// Mirrors TypeScript `FileSettingsStorage`:
1040/// - the settings file itself is locked (`settings.json.lock` sibling)
1041/// - a missing file is read without acquiring the lock when no write happens
1042/// - the parent directory is created only when content is actually written
1043#[derive(Debug)]
1044pub struct FileSettingsStorage {
1045    global_settings_path: PathBuf,
1046    project_settings_path: PathBuf,
1047}
1048
1049impl FileSettingsStorage {
1050    /// Create storage rooted at `cwd` (project) and `agent_dir` (global).
1051    #[must_use]
1052    pub fn new(cwd: impl AsRef<Path>, agent_dir: impl AsRef<Path>) -> Self {
1053        let resolved_cwd = resolve_path(path_to_string(cwd.as_ref()));
1054        let resolved_agent_dir = resolve_path(path_to_string(agent_dir.as_ref()));
1055        Self {
1056            global_settings_path: resolved_agent_dir.join("settings.json"),
1057            project_settings_path: resolved_cwd.join(CONFIG_DIR_NAME).join("settings.json"),
1058        }
1059    }
1060
1061    /// Path of the global settings file.
1062    #[must_use]
1063    pub fn global_settings_path(&self) -> &Path {
1064        &self.global_settings_path
1065    }
1066
1067    /// Path of the project settings file.
1068    #[must_use]
1069    pub fn project_settings_path(&self) -> &Path {
1070        &self.project_settings_path
1071    }
1072
1073    fn path_for(&self, scope: SettingsScope) -> &Path {
1074        match scope {
1075            SettingsScope::Global => &self.global_settings_path,
1076            SettingsScope::Project => &self.project_settings_path,
1077        }
1078    }
1079}
1080
1081impl SettingsStorage for FileSettingsStorage {
1082    fn with_lock(
1083        &mut self,
1084        scope: SettingsScope,
1085        f: &mut dyn FnMut(Option<String>) -> Result<Option<String>, String>,
1086    ) -> Result<(), String> {
1087        let path = self.path_for(scope).to_path_buf();
1088        let dir = path
1089            .parent()
1090            .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
1091
1092        let file_exists = path.exists();
1093        let mut guard = if file_exists {
1094            Some(LockGuard::acquire(&path).map_err(|error| error.to_string())?)
1095        } else {
1096            None
1097        };
1098        let current =
1099            if file_exists {
1100                Some(fs::read_to_string(&path).map_err(|error| {
1101                    format!("Failed to read settings {}: {error}", path.display())
1102                })?)
1103            } else {
1104                None
1105            };
1106        let next = f(current)?;
1107        if let Some(next) = next {
1108            if !dir.exists() {
1109                fs::create_dir_all(&dir).map_err(|error| {
1110                    format!(
1111                        "Failed to create settings directory {}: {error}",
1112                        dir.display()
1113                    )
1114                })?;
1115            }
1116            if guard.is_none() {
1117                guard = Some(LockGuard::acquire(&path).map_err(|error| error.to_string())?);
1118            }
1119            fs::write(&path, next)
1120                .map_err(|error| format!("Failed to write settings {}: {error}", path.display()))?;
1121        }
1122        drop(guard);
1123        Ok(())
1124    }
1125}
1126
1127/// In-memory storage backend (no file I/O).
1128#[derive(Debug, Default)]
1129pub struct InMemorySettingsStorage {
1130    global: Option<String>,
1131    project: Option<String>,
1132}
1133
1134impl InMemorySettingsStorage {
1135    /// Create empty in-memory storage.
1136    #[must_use]
1137    pub fn new() -> Self {
1138        Self::default()
1139    }
1140}
1141
1142impl SettingsStorage for InMemorySettingsStorage {
1143    fn with_lock(
1144        &mut self,
1145        scope: SettingsScope,
1146        f: &mut dyn FnMut(Option<String>) -> Result<Option<String>, String>,
1147    ) -> Result<(), String> {
1148        let slot = match scope {
1149            SettingsScope::Global => &mut self.global,
1150            SettingsScope::Project => &mut self.project,
1151        };
1152        let next = f(slot.clone())?;
1153        if let Some(next) = next {
1154            *slot = Some(next);
1155        }
1156        Ok(())
1157    }
1158}
1159
1160/// Settings manager: loads, migrates, merges, and persists global and
1161/// trust-gated project settings.
1162///
1163/// Internally documents are raw JSON objects; merges and migrations operate at
1164/// the JSON level exactly like the TypeScript implementation. Writes are
1165/// performed synchronously inside setters; storage errors are recorded and
1166/// surfaced through [`Self::drain_errors`]. The only methods that return
1167/// errors are project writes while untrusted and the timeout getter/setter
1168/// validations.
1169pub struct SettingsManager {
1170    storage: Box<dyn SettingsStorage>,
1171    global_settings: Map<String, Value>,
1172    project_settings: Map<String, Value>,
1173    settings: Map<String, Value>,
1174    project_trusted: bool,
1175    modified_fields: BTreeSet<String>,
1176    modified_nested_fields: BTreeMap<String, BTreeSet<String>>,
1177    modified_project_fields: BTreeSet<String>,
1178    modified_project_nested_fields: BTreeMap<String, BTreeSet<String>>,
1179    global_settings_load_error: Option<String>,
1180    project_settings_load_error: Option<String>,
1181    errors: Vec<SettingsError>,
1182    /// Serializes in-process writes so [`Self::flush`] can wait for them.
1183    write_mutex: Mutex<()>,
1184}
1185
1186impl SettingsManager {
1187    fn new(
1188        storage: Box<dyn SettingsStorage>,
1189        global_settings: Map<String, Value>,
1190        project_settings: Map<String, Value>,
1191        global_load_error: Option<String>,
1192        project_load_error: Option<String>,
1193        initial_errors: Vec<SettingsError>,
1194        project_trusted: bool,
1195    ) -> Self {
1196        let settings = deep_merge_settings(&global_settings, &project_settings);
1197        Self {
1198            storage,
1199            global_settings,
1200            project_settings,
1201            settings,
1202            project_trusted,
1203            modified_fields: BTreeSet::new(),
1204            modified_nested_fields: BTreeMap::new(),
1205            modified_project_fields: BTreeSet::new(),
1206            modified_project_nested_fields: BTreeMap::new(),
1207            global_settings_load_error: global_load_error,
1208            project_settings_load_error: project_load_error,
1209            errors: initial_errors,
1210            write_mutex: Mutex::new(()),
1211        }
1212    }
1213
1214    /// Create a manager backed by settings files under `agent_dir`
1215    /// (default: [`get_agent_dir`]) and `{cwd}/.pi`.
1216    #[must_use]
1217    pub fn create(
1218        cwd: impl AsRef<Path>,
1219        agent_dir: Option<impl AsRef<Path>>,
1220        options: SettingsManagerCreateOptions,
1221    ) -> Self {
1222        let agent_dir = agent_dir.map_or_else(get_agent_dir, |dir| dir.as_ref().to_path_buf());
1223        Self::from_storage(Box::new(FileSettingsStorage::new(cwd, agent_dir)), options)
1224    }
1225
1226    /// Create a manager from an arbitrary storage backend.
1227    #[must_use]
1228    pub fn from_storage(
1229        mut storage: Box<dyn SettingsStorage>,
1230        options: SettingsManagerCreateOptions,
1231    ) -> Self {
1232        let project_trusted = options.project_trusted;
1233        let (global_settings, global_error) =
1234            Self::try_load_from_storage(storage.as_mut(), SettingsScope::Global, true);
1235        let (project_settings, project_error) =
1236            Self::try_load_from_storage(storage.as_mut(), SettingsScope::Project, project_trusted);
1237        let mut initial_errors = Vec::new();
1238        if let Some(message) = &global_error {
1239            initial_errors.push(SettingsError {
1240                scope: SettingsScope::Global,
1241                message: message.clone(),
1242            });
1243        }
1244        if let Some(message) = &project_error {
1245            initial_errors.push(SettingsError {
1246                scope: SettingsScope::Project,
1247                message: message.clone(),
1248            });
1249        }
1250        Self::new(
1251            storage,
1252            global_settings,
1253            project_settings,
1254            global_error,
1255            project_error,
1256            initial_errors,
1257            project_trusted,
1258        )
1259    }
1260
1261    /// Create an in-memory manager (no file I/O) seeded with `settings`.
1262    #[must_use]
1263    pub fn in_memory(settings: &Settings, options: SettingsManagerCreateOptions) -> Self {
1264        let mut storage = InMemorySettingsStorage::new();
1265        let mut initial = settings.to_map();
1266        migrate_settings(&mut initial);
1267        let seed =
1268            serde_json::to_string_pretty(&Value::Object(initial)).unwrap_or_else(|_| "{}".into());
1269        let _ = storage.with_lock(SettingsScope::Global, &mut |_| Ok(Some(seed.clone())));
1270        Self::from_storage(Box::new(storage), options)
1271    }
1272
1273    fn load_from_storage(
1274        storage: &mut dyn SettingsStorage,
1275        scope: SettingsScope,
1276        project_trusted: bool,
1277    ) -> Result<Map<String, Value>, String> {
1278        if scope == SettingsScope::Project && !project_trusted {
1279            return Ok(Map::new());
1280        }
1281        let mut content: Option<String> = None;
1282        storage.with_lock(scope, &mut |current| {
1283            content = current;
1284            Ok(None)
1285        })?;
1286        // `if (!content) return {}` — missing *or empty* file loads as empty.
1287        let Some(text) = content.filter(|text| !text.is_empty()) else {
1288            return Ok(Map::new());
1289        };
1290        parse_settings_text(&text)
1291    }
1292
1293    fn try_load_from_storage(
1294        storage: &mut dyn SettingsStorage,
1295        scope: SettingsScope,
1296        project_trusted: bool,
1297    ) -> (Map<String, Value>, Option<String>) {
1298        match Self::load_from_storage(storage, scope, project_trusted) {
1299            Ok(settings) => (settings, None),
1300            Err(message) => (Map::new(), Some(message)),
1301        }
1302    }
1303
1304    /// Typed view of the global settings document.
1305    #[must_use]
1306    pub fn get_global_settings(&self) -> Settings {
1307        Settings::from_map(&self.global_settings)
1308    }
1309
1310    /// Typed view of the project settings document (empty while untrusted).
1311    #[must_use]
1312    pub fn get_project_settings(&self) -> Settings {
1313        Settings::from_map(&self.project_settings)
1314    }
1315
1316    /// Whether the project is currently trusted.
1317    #[must_use]
1318    pub const fn is_project_trusted(&self) -> bool {
1319        self.project_trusted
1320    }
1321
1322    /// Change project trust. `false` discards project data; `true` reloads it.
1323    pub fn set_project_trusted(&mut self, trusted: bool) {
1324        if self.project_trusted == trusted {
1325            return;
1326        }
1327        self.project_trusted = trusted;
1328        self.modified_project_fields.clear();
1329        self.modified_project_nested_fields.clear();
1330        if !trusted {
1331            self.project_settings = Map::new();
1332            self.project_settings_load_error = None;
1333            self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1334            return;
1335        }
1336        let (project_settings, project_error) =
1337            Self::try_load_from_storage(self.storage.as_mut(), SettingsScope::Project, true);
1338        self.project_settings = project_settings;
1339        self.project_settings_load_error.clone_from(&project_error);
1340        if let Some(message) = project_error {
1341            self.record_error(SettingsScope::Project, message);
1342        }
1343        self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1344    }
1345
1346    /// Reload both scopes from storage, discarding pending modifications.
1347    pub fn reload(&mut self) {
1348        let (global_settings, global_error) =
1349            Self::try_load_from_storage(self.storage.as_mut(), SettingsScope::Global, true);
1350        match global_error {
1351            None => {
1352                self.global_settings = global_settings;
1353                self.global_settings_load_error = None;
1354            }
1355            Some(message) => {
1356                self.global_settings_load_error = Some(message.clone());
1357                self.record_error(SettingsScope::Global, message);
1358            }
1359        }
1360        self.modified_fields.clear();
1361        self.modified_nested_fields.clear();
1362        self.modified_project_fields.clear();
1363        self.modified_project_nested_fields.clear();
1364        let (project_settings, project_error) = Self::try_load_from_storage(
1365            self.storage.as_mut(),
1366            SettingsScope::Project,
1367            self.project_trusted,
1368        );
1369        match project_error {
1370            None => {
1371                self.project_settings = project_settings;
1372                self.project_settings_load_error = None;
1373            }
1374            Some(message) => {
1375                self.project_settings_load_error = Some(message.clone());
1376                self.record_error(SettingsScope::Project, message);
1377            }
1378        }
1379        self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1380    }
1381
1382    /// Apply additional overrides on top of the merged settings.
1383    ///
1384    /// Overrides are not persisted and are discarded by the next save (TS parity).
1385    pub fn apply_overrides(&mut self, overrides: &Map<String, Value>) {
1386        self.settings = deep_merge_settings(&self.settings, overrides);
1387    }
1388
1389    /// Wait for any in-process write to finish.
1390    ///
1391    /// Setters acquire the same mutex around persistence, so this blocks until
1392    /// concurrent writers (if any) complete, matching TypeScript `flush` after
1393    /// the write queue drains.
1394    pub fn flush(&self) {
1395        let _guard = match self.write_mutex.lock() {
1396            Ok(guard) => guard,
1397            Err(poisoned) => poisoned.into_inner(),
1398        };
1399    }
1400
1401    /// Take all recorded errors, leaving the error list empty.
1402    #[must_use]
1403    pub fn drain_errors(&mut self) -> Vec<SettingsError> {
1404        std::mem::take(&mut self.errors)
1405    }
1406
1407    fn record_error(&mut self, scope: SettingsScope, message: String) {
1408        self.errors.push(SettingsError { scope, message });
1409    }
1410
1411    fn clear_modified_scope(&mut self, scope: SettingsScope) {
1412        match scope {
1413            SettingsScope::Global => {
1414                self.modified_fields.clear();
1415                self.modified_nested_fields.clear();
1416            }
1417            SettingsScope::Project => {
1418                self.modified_project_fields.clear();
1419                self.modified_project_nested_fields.clear();
1420            }
1421        }
1422    }
1423
1424    fn assert_project_trusted_for_write(&self) -> Result<(), SettingsManagerError> {
1425        if self.project_trusted {
1426            Ok(())
1427        } else {
1428            Err(SettingsManagerError::ProjectNotTrusted)
1429        }
1430    }
1431
1432    fn persist_scoped_settings(
1433        &mut self,
1434        scope: SettingsScope,
1435        snapshot_settings: &Map<String, Value>,
1436        modified_fields: &BTreeSet<String>,
1437        modified_nested_fields: &BTreeMap<String, BTreeSet<String>>,
1438    ) -> Result<(), String> {
1439        if scope == SettingsScope::Project && !self.project_trusted {
1440            return Err(SettingsManagerError::ProjectNotTrusted.to_string());
1441        }
1442        let _write_guard = match self.write_mutex.lock() {
1443            Ok(guard) => guard,
1444            Err(poisoned) => poisoned.into_inner(),
1445        };
1446        self.storage.with_lock(scope, &mut |current| {
1447            let current_file_settings = match current {
1448                Some(text) if !text.is_empty() => parse_settings_text(&text)?,
1449                _ => Map::new(),
1450            };
1451            let mut merged_settings = current_file_settings.clone();
1452            for field in modified_fields {
1453                let value = snapshot_settings.get(field);
1454                match (modified_nested_fields.get(field), value) {
1455                    (Some(nested_modified), Some(Value::Object(in_memory_nested))) => {
1456                        let mut merged_nested = match current_file_settings.get(field) {
1457                            Some(Value::Object(base_nested)) => base_nested.clone(),
1458                            _ => Map::new(),
1459                        };
1460                        for nested_key in nested_modified {
1461                            match in_memory_nested.get(nested_key) {
1462                                Some(nested_value) => {
1463                                    merged_nested.insert(nested_key.clone(), nested_value.clone());
1464                                }
1465                                None => {
1466                                    merged_nested.remove(nested_key);
1467                                }
1468                            }
1469                        }
1470                        merged_settings.insert(field.clone(), Value::Object(merged_nested));
1471                    }
1472                    _ => match value {
1473                        Some(value) => {
1474                            merged_settings.insert(field.clone(), value.clone());
1475                        }
1476                        None => {
1477                            merged_settings.remove(field);
1478                        }
1479                    },
1480                }
1481            }
1482            serde_json::to_string_pretty(&Value::Object(merged_settings))
1483                .map(Some)
1484                .map_err(|error| error.to_string())
1485        })
1486    }
1487
1488    fn save(&mut self) {
1489        self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1490        if self.global_settings_load_error.is_some() {
1491            return;
1492        }
1493        let snapshot = self.global_settings.clone();
1494        let modified_fields = self.modified_fields.clone();
1495        let modified_nested_fields = self.modified_nested_fields.clone();
1496        match self.persist_scoped_settings(
1497            SettingsScope::Global,
1498            &snapshot,
1499            &modified_fields,
1500            &modified_nested_fields,
1501        ) {
1502            Ok(()) => self.clear_modified_scope(SettingsScope::Global),
1503            Err(message) => self.record_error(SettingsScope::Global, message),
1504        }
1505    }
1506
1507    fn save_project_settings(
1508        &mut self,
1509        settings: Map<String, Value>,
1510    ) -> Result<(), SettingsManagerError> {
1511        self.assert_project_trusted_for_write()?;
1512        self.project_settings = settings;
1513        self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1514        if self.project_settings_load_error.is_some() {
1515            return Ok(());
1516        }
1517        let snapshot = self.project_settings.clone();
1518        let modified_fields = self.modified_project_fields.clone();
1519        let modified_nested_fields = self.modified_project_nested_fields.clone();
1520        match self.persist_scoped_settings(
1521            SettingsScope::Project,
1522            &snapshot,
1523            &modified_fields,
1524            &modified_nested_fields,
1525        ) {
1526            Ok(()) => {
1527                self.clear_modified_scope(SettingsScope::Project);
1528                Ok(())
1529            }
1530            Err(message) => {
1531                self.record_error(SettingsScope::Project, message);
1532                Ok(())
1533            }
1534        }
1535    }
1536
1537    fn update_project_settings(
1538        &mut self,
1539        field: &'static str,
1540        update: impl FnOnce(&mut Map<String, Value>),
1541    ) -> Result<(), SettingsManagerError> {
1542        self.assert_project_trusted_for_write()?;
1543        let mut project_settings = self.project_settings.clone();
1544        update(&mut project_settings);
1545        self.modified_project_fields.insert(field.to_owned());
1546        self.save_project_settings(project_settings)
1547    }
1548
1549    fn set_global_field(&mut self, field: &'static str, value: Value) {
1550        self.global_settings.insert(field.to_owned(), value);
1551        self.modified_fields.insert(field.to_owned());
1552        self.save();
1553    }
1554
1555    fn set_global_optional_field(&mut self, field: &'static str, value: Option<Value>) {
1556        match value {
1557            Some(value) => {
1558                self.global_settings.insert(field.to_owned(), value);
1559            }
1560            None => {
1561                self.global_settings.remove(field);
1562            }
1563        }
1564        self.modified_fields.insert(field.to_owned());
1565        self.save();
1566    }
1567
1568    fn set_global_nested_field(
1569        &mut self,
1570        field: &'static str,
1571        nested_key: &'static str,
1572        value: Value,
1573    ) {
1574        let mut nested = match self.global_settings.get_mut(field) {
1575            Some(Value::Object(object)) => std::mem::take(object),
1576            _ => Map::new(),
1577        };
1578        nested.insert(nested_key.to_owned(), value);
1579        self.global_settings
1580            .insert(field.to_owned(), Value::Object(nested));
1581        self.modified_fields.insert(field.to_owned());
1582        self.modified_nested_fields
1583            .entry(field.to_owned())
1584            .or_default()
1585            .insert(nested_key.to_owned());
1586        self.save();
1587    }
1588
1589    fn merged_bool(&self, key: &str) -> Option<bool> {
1590        self.settings.get(key).and_then(Value::as_bool)
1591    }
1592
1593    fn merged_nested_bool(&self, key: &str, nested_key: &str) -> Option<bool> {
1594        self.settings
1595            .get(key)
1596            .and_then(Value::as_object)?
1597            .get(nested_key)?
1598            .as_bool()
1599    }
1600
1601    fn merged_nested_u64(&self, key: &str, nested_key: &str) -> Option<u64> {
1602        number_to_u64(
1603            self.settings
1604                .get(key)
1605                .and_then(Value::as_object)?
1606                .get(nested_key),
1607        )
1608    }
1609
1610    fn merged_string_array(&self, key: &str) -> Vec<String> {
1611        string_array(self.settings.get(key)).unwrap_or_default()
1612    }
1613
1614    // -- Changelog / session -------------------------------------------------
1615
1616    /// `lastChangelogVersion` from merged settings.
1617    #[must_use]
1618    pub fn get_last_changelog_version(&self) -> Option<String> {
1619        string_field(&self.settings, "lastChangelogVersion")
1620    }
1621
1622    /// Set `lastChangelogVersion` (global).
1623    pub fn set_last_changelog_version(&mut self, version: &str) {
1624        self.set_global_field("lastChangelogVersion", Value::String(version.to_owned()));
1625    }
1626
1627    /// `sessionDir` with `~` expansion; empty string returned verbatim.
1628    #[must_use]
1629    pub fn get_session_dir(&self) -> Option<String> {
1630        normalize_optional_path(self.settings.get("sessionDir"))
1631    }
1632
1633    // -- Model ----------------------------------------------------------------
1634
1635    /// `defaultProvider` from merged settings.
1636    #[must_use]
1637    pub fn get_default_provider(&self) -> Option<String> {
1638        string_field(&self.settings, "defaultProvider")
1639    }
1640
1641    /// `defaultModel` from merged settings.
1642    #[must_use]
1643    pub fn get_default_model(&self) -> Option<String> {
1644        string_field(&self.settings, "defaultModel")
1645    }
1646
1647    /// Set `defaultProvider` (global).
1648    pub fn set_default_provider(&mut self, provider: &str) {
1649        self.set_global_field("defaultProvider", Value::String(provider.to_owned()));
1650    }
1651
1652    /// Set `defaultModel` (global).
1653    pub fn set_default_model(&mut self, model_id: &str) {
1654        self.set_global_field("defaultModel", Value::String(model_id.to_owned()));
1655    }
1656
1657    /// Set both `defaultProvider` and `defaultModel` with one save (global).
1658    pub fn set_default_model_and_provider(&mut self, provider: &str, model_id: &str) {
1659        self.global_settings.insert(
1660            "defaultProvider".to_owned(),
1661            Value::String(provider.to_owned()),
1662        );
1663        self.global_settings.insert(
1664            "defaultModel".to_owned(),
1665            Value::String(model_id.to_owned()),
1666        );
1667        self.modified_fields.insert("defaultProvider".to_owned());
1668        self.modified_fields.insert("defaultModel".to_owned());
1669        self.save();
1670    }
1671
1672    /// `defaultThinkingLevel` from merged settings.
1673    #[must_use]
1674    pub fn get_default_thinking_level(&self) -> Option<ModelThinkingLevel> {
1675        parse_thinking_level(self.settings.get("defaultThinkingLevel"))
1676    }
1677
1678    /// Set `defaultThinkingLevel` (global).
1679    pub fn set_default_thinking_level(&mut self, level: ModelThinkingLevel) {
1680        self.set_global_field("defaultThinkingLevel", thinking_level_value(level));
1681    }
1682
1683    /// `enabledModels` from merged settings.
1684    #[must_use]
1685    pub fn get_enabled_models(&self) -> Option<Vec<String>> {
1686        string_array(self.settings.get("enabledModels"))
1687    }
1688
1689    /// Set `enabledModels`; `None` removes the key (global).
1690    pub fn set_enabled_models(&mut self, patterns: Option<Vec<String>>) {
1691        self.set_global_optional_field(
1692            "enabledModels",
1693            patterns
1694                .map(|patterns| Value::Array(patterns.into_iter().map(Value::String).collect())),
1695        );
1696    }
1697
1698    /// `thinkingBudgets` from merged settings.
1699    #[must_use]
1700    pub fn get_thinking_budgets(&self) -> Option<ThinkingBudgetsSettings> {
1701        nested_field(
1702            &self.settings,
1703            "thinkingBudgets",
1704            ThinkingBudgetsSettings::from_map,
1705        )
1706    }
1707
1708    // -- Modes ----------------------------------------------------------------
1709
1710    /// Steering queue drain mode (default: one-at-a-time).
1711    #[must_use]
1712    pub fn get_steering_mode(&self) -> QueueMode {
1713        parse_queue_mode(self.settings.get("steeringMode")).unwrap_or_default()
1714    }
1715
1716    /// Set `steeringMode` (global).
1717    pub fn set_steering_mode(&mut self, mode: QueueMode) {
1718        self.set_global_field("steeringMode", queue_mode_value(mode));
1719    }
1720
1721    /// Follow-up queue drain mode (default: one-at-a-time).
1722    #[must_use]
1723    pub fn get_follow_up_mode(&self) -> QueueMode {
1724        parse_queue_mode(self.settings.get("followUpMode")).unwrap_or_default()
1725    }
1726
1727    /// Set `followUpMode` (global).
1728    pub fn set_follow_up_mode(&mut self, mode: QueueMode) {
1729        self.set_global_field("followUpMode", queue_mode_value(mode));
1730    }
1731
1732    /// Streaming transport preference (default: auto).
1733    #[must_use]
1734    pub fn get_transport(&self) -> Transport {
1735        parse_transport(self.settings.get("transport")).unwrap_or(Transport::Auto)
1736    }
1737
1738    /// Set `transport` (global).
1739    pub fn set_transport(&mut self, transport: Transport) {
1740        self.set_global_field("transport", transport_value(transport));
1741    }
1742
1743    // -- Theme ----------------------------------------------------------------
1744
1745    /// Raw `theme` setting when it is a string.
1746    #[must_use]
1747    pub fn get_theme_setting(&self) -> Option<String> {
1748        string_field(&self.settings, "theme")
1749    }
1750
1751    /// Theme name; `None` when unset or path-like (contains `/`).
1752    #[must_use]
1753    pub fn get_theme(&self) -> Option<String> {
1754        self.get_theme_setting()
1755            .filter(|theme| !theme.contains('/'))
1756    }
1757
1758    /// Set `theme` (global).
1759    pub fn set_theme(&mut self, theme: &str) {
1760        self.set_global_field("theme", Value::String(theme.to_owned()));
1761    }
1762
1763    /// `themes` resource paths from merged settings.
1764    #[must_use]
1765    pub fn get_theme_paths(&self) -> Vec<String> {
1766        self.merged_string_array("themes")
1767    }
1768
1769    /// Set `themes` resource paths (global).
1770    pub fn set_theme_paths(&mut self, paths: Vec<String>) {
1771        self.set_global_field(
1772            "themes",
1773            Value::Array(paths.into_iter().map(Value::String).collect()),
1774        );
1775    }
1776
1777    /// Set `themes` resource paths in the project file.
1778    ///
1779    /// # Errors
1780    ///
1781    /// Returns [`SettingsManagerError::ProjectNotTrusted`] when untrusted.
1782    pub fn set_project_theme_paths(
1783        &mut self,
1784        paths: Vec<String>,
1785    ) -> Result<(), SettingsManagerError> {
1786        self.update_project_settings("themes", |settings| {
1787            settings.insert(
1788                "themes".to_owned(),
1789                Value::Array(paths.into_iter().map(Value::String).collect()),
1790            );
1791        })
1792    }
1793
1794    // -- Compaction / branch summary ------------------------------------------
1795
1796    /// `compaction.enabled` (default: true).
1797    #[must_use]
1798    pub fn get_compaction_enabled(&self) -> bool {
1799        self.merged_nested_bool("compaction", "enabled")
1800            .unwrap_or(true)
1801    }
1802
1803    /// Set `compaction.enabled` (global, nested merge).
1804    pub fn set_compaction_enabled(&mut self, enabled: bool) {
1805        self.set_global_nested_field("compaction", "enabled", Value::Bool(enabled));
1806    }
1807
1808    /// `compaction.reserveTokens` (default: 16384).
1809    #[must_use]
1810    pub fn get_compaction_reserve_tokens(&self) -> u64 {
1811        self.merged_nested_u64("compaction", "reserveTokens")
1812            .unwrap_or(DEFAULT_COMPACTION_RESERVE_TOKENS)
1813    }
1814
1815    /// `compaction.keepRecentTokens` (default: 20000).
1816    #[must_use]
1817    pub fn get_compaction_keep_recent_tokens(&self) -> u64 {
1818        self.merged_nested_u64("compaction", "keepRecentTokens")
1819            .unwrap_or(DEFAULT_COMPACTION_KEEP_RECENT_TOKENS)
1820    }
1821
1822    /// Fully-resolved compaction settings.
1823    #[must_use]
1824    pub fn get_compaction_settings(&self) -> ResolvedCompactionSettings {
1825        ResolvedCompactionSettings {
1826            enabled: self.get_compaction_enabled(),
1827            reserve_tokens: self.get_compaction_reserve_tokens(),
1828            keep_recent_tokens: self.get_compaction_keep_recent_tokens(),
1829        }
1830    }
1831
1832    /// Fully-resolved branch-summary settings.
1833    #[must_use]
1834    pub fn get_branch_summary_settings(&self) -> ResolvedBranchSummarySettings {
1835        ResolvedBranchSummarySettings {
1836            reserve_tokens: self
1837                .merged_nested_u64("branchSummary", "reserveTokens")
1838                .unwrap_or(DEFAULT_BRANCH_SUMMARY_RESERVE_TOKENS),
1839            skip_prompt: self.get_branch_summary_skip_prompt(),
1840        }
1841    }
1842
1843    /// `branchSummary.skipPrompt` (default: false).
1844    #[must_use]
1845    pub fn get_branch_summary_skip_prompt(&self) -> bool {
1846        self.merged_nested_bool("branchSummary", "skipPrompt")
1847            .unwrap_or(false)
1848    }
1849
1850    // -- Retry / HTTP ---------------------------------------------------------
1851
1852    /// `retry.enabled` (default: true).
1853    #[must_use]
1854    pub fn get_retry_enabled(&self) -> bool {
1855        self.merged_nested_bool("retry", "enabled").unwrap_or(true)
1856    }
1857
1858    /// Set `retry.enabled` (global, nested merge).
1859    pub fn set_retry_enabled(&mut self, enabled: bool) {
1860        self.set_global_nested_field("retry", "enabled", Value::Bool(enabled));
1861    }
1862
1863    /// Fully-resolved automatic retry settings.
1864    #[must_use]
1865    pub fn get_retry_settings(&self) -> ResolvedRetrySettings {
1866        ResolvedRetrySettings {
1867            enabled: self.get_retry_enabled(),
1868            max_retries: self
1869                .merged_nested_u64("retry", "maxRetries")
1870                .unwrap_or(DEFAULT_RETRY_MAX_RETRIES),
1871            base_delay_ms: self
1872                .merged_nested_u64("retry", "baseDelayMs")
1873                .unwrap_or(DEFAULT_RETRY_BASE_DELAY_MS),
1874        }
1875    }
1876
1877    /// Fully-resolved provider retry settings (`maxRetryDelayMs` default 60000).
1878    #[must_use]
1879    pub fn get_provider_retry_settings(&self) -> ResolvedProviderRetrySettings {
1880        let provider = self
1881            .settings
1882            .get("retry")
1883            .and_then(Value::as_object)
1884            .and_then(|retry| retry.get("provider").and_then(Value::as_object));
1885        ResolvedProviderRetrySettings {
1886            timeout_ms: provider.and_then(|p| number_to_u64(p.get("timeoutMs"))),
1887            max_retries: provider.and_then(|p| number_to_u64(p.get("maxRetries"))),
1888            max_retry_delay_ms: provider
1889                .and_then(|p| number_to_u64(p.get("maxRetryDelayMs")))
1890                .unwrap_or(DEFAULT_PROVIDER_MAX_RETRY_DELAY_MS),
1891        }
1892    }
1893
1894    /// `httpIdleTimeoutMs` (default: 300000).
1895    ///
1896    /// # Errors
1897    ///
1898    /// Returns [`SettingsManagerError::InvalidSetting`] when a stored value is
1899    /// present but not a usable timeout.
1900    pub fn get_http_idle_timeout_ms(&self) -> Result<u64, SettingsManagerError> {
1901        Ok(
1902            parse_timeout_setting(self.settings.get("httpIdleTimeoutMs"), "httpIdleTimeoutMs")?
1903                .unwrap_or(DEFAULT_HTTP_IDLE_TIMEOUT_MS),
1904        )
1905    }
1906
1907    /// Set `httpIdleTimeoutMs` (global); the value is floored.
1908    ///
1909    /// # Errors
1910    ///
1911    /// Returns [`SettingsManagerError::InvalidSetting`] when non-finite or negative.
1912    pub fn set_http_idle_timeout_ms(
1913        &mut self,
1914        timeout_ms: f64,
1915    ) -> Result<(), SettingsManagerError> {
1916        if !timeout_ms.is_finite() || timeout_ms < 0.0 {
1917            return Err(SettingsManagerError::InvalidSetting {
1918                setting: "httpIdleTimeoutMs",
1919                value: js_number_to_string(timeout_ms),
1920            });
1921        }
1922        self.set_global_field("httpIdleTimeoutMs", json_floored_number(timeout_ms));
1923        Ok(())
1924    }
1925
1926    /// `websocketConnectTimeoutMs`; `None` when unset.
1927    ///
1928    /// # Errors
1929    ///
1930    /// Returns [`SettingsManagerError::InvalidSetting`] when a stored value is
1931    /// present but not a usable timeout.
1932    pub fn get_web_socket_connect_timeout_ms(&self) -> Result<Option<u64>, SettingsManagerError> {
1933        parse_timeout_setting(
1934            self.settings.get("websocketConnectTimeoutMs"),
1935            "websocketConnectTimeoutMs",
1936        )
1937    }
1938
1939    // -- UI flags -------------------------------------------------------------
1940
1941    /// `hideThinkingBlock` (default: false).
1942    #[must_use]
1943    pub fn get_hide_thinking_block(&self) -> bool {
1944        self.merged_bool("hideThinkingBlock").unwrap_or(false)
1945    }
1946
1947    /// `showCacheMissNotices` (default: false).
1948    #[must_use]
1949    pub fn get_show_cache_miss_notices(&self) -> bool {
1950        self.merged_bool("showCacheMissNotices").unwrap_or(false)
1951    }
1952
1953    /// External editor command: configured → VISUAL → EDITOR → notepad/nano.
1954    #[must_use]
1955    pub fn get_external_editor_command(&self) -> String {
1956        if let Some(configured) = string_field(&self.settings, "externalEditor")
1957            && !configured.trim().is_empty()
1958        {
1959            return configured;
1960        }
1961        if let Some(editor) = env::var("VISUAL").ok().filter(|value| !value.is_empty()) {
1962            return editor;
1963        }
1964        if let Some(editor) = env::var("EDITOR").ok().filter(|value| !value.is_empty()) {
1965            return editor;
1966        }
1967        if cfg!(windows) {
1968            "notepad".to_owned()
1969        } else {
1970            "nano".to_owned()
1971        }
1972    }
1973
1974    /// Set `hideThinkingBlock` (global).
1975    pub fn set_hide_thinking_block(&mut self, hide: bool) {
1976        self.set_global_field("hideThinkingBlock", Value::Bool(hide));
1977    }
1978
1979    /// Set `showCacheMissNotices` (global).
1980    pub fn set_show_cache_miss_notices(&mut self, show: bool) {
1981        self.set_global_field("showCacheMissNotices", Value::Bool(show));
1982    }
1983
1984    /// `quietStartup` (default: false).
1985    #[must_use]
1986    pub fn get_quiet_startup(&self) -> bool {
1987        self.merged_bool("quietStartup").unwrap_or(false)
1988    }
1989
1990    /// Set `quietStartup` (global).
1991    pub fn set_quiet_startup(&mut self, quiet: bool) {
1992        self.set_global_field("quietStartup", Value::Bool(quiet));
1993    }
1994
1995    /// `doubleEscapeAction` (default: tree).
1996    #[must_use]
1997    pub fn get_double_escape_action(&self) -> DoubleEscapeAction {
1998        self.settings
1999            .get("doubleEscapeAction")
2000            .and_then(Value::as_str)
2001            .and_then(DoubleEscapeAction::parse)
2002            .unwrap_or_default()
2003    }
2004
2005    /// Set `doubleEscapeAction` (global).
2006    pub fn set_double_escape_action(&mut self, action: DoubleEscapeAction) {
2007        self.set_global_field(
2008            "doubleEscapeAction",
2009            Value::String(action.as_str().to_owned()),
2010        );
2011    }
2012
2013    /// `treeFilterMode`; an unrecognized stored value yields `default`.
2014    #[must_use]
2015    pub fn get_tree_filter_mode(&self) -> TreeFilterMode {
2016        self.settings
2017            .get("treeFilterMode")
2018            .and_then(Value::as_str)
2019            .and_then(TreeFilterMode::parse)
2020            .unwrap_or_default()
2021    }
2022
2023    /// Set `treeFilterMode` (global).
2024    pub fn set_tree_filter_mode(&mut self, mode: TreeFilterMode) {
2025        self.set_global_field("treeFilterMode", Value::String(mode.as_str().to_owned()));
2026    }
2027
2028    /// `showHardwareCursor`; falls back to `PI_HARDWARE_CURSOR === "1"`.
2029    #[must_use]
2030    pub fn get_show_hardware_cursor(&self) -> bool {
2031        self.merged_bool("showHardwareCursor")
2032            .unwrap_or_else(|| env_flag("PI_HARDWARE_CURSOR"))
2033    }
2034
2035    /// Set `showHardwareCursor` (global).
2036    pub fn set_show_hardware_cursor(&mut self, enabled: bool) {
2037        self.set_global_field("showHardwareCursor", Value::Bool(enabled));
2038    }
2039
2040    /// `editorPaddingX` (default: 0; the setter clamps to 0..=3).
2041    #[must_use]
2042    pub fn get_editor_padding_x(&self) -> u64 {
2043        number_to_u64(self.settings.get("editorPaddingX")).unwrap_or(0)
2044    }
2045
2046    /// Set `editorPaddingX` (global), clamped to `0..=3` after flooring.
2047    pub fn set_editor_padding_x(&mut self, padding: f64) {
2048        self.set_global_field(
2049            "editorPaddingX",
2050            js_clamped_floored_number(padding, 0.0, 3.0),
2051        );
2052    }
2053
2054    /// `outputPad`; only an exact numeric `0` yields [`OutputPad::Zero`].
2055    #[must_use]
2056    pub fn get_output_pad(&self) -> OutputPad {
2057        let is_zero = self
2058            .settings
2059            .get("outputPad")
2060            .and_then(Value::as_f64)
2061            .is_some_and(|value| js_number_eq(value, 0.0));
2062        if is_zero {
2063            OutputPad::Zero
2064        } else {
2065            OutputPad::One
2066        }
2067    }
2068
2069    /// Set `outputPad` (global).
2070    pub fn set_output_pad(&mut self, padding: OutputPad) {
2071        self.set_global_field("outputPad", Value::from(padding.as_u64()));
2072    }
2073
2074    /// `autocompleteMaxVisible` (default: 5; the setter clamps to 3..=20).
2075    #[must_use]
2076    pub fn get_autocomplete_max_visible(&self) -> u64 {
2077        number_to_u64(self.settings.get("autocompleteMaxVisible"))
2078            .unwrap_or(DEFAULT_AUTOCOMPLETE_MAX_VISIBLE)
2079    }
2080
2081    /// Set `autocompleteMaxVisible` (global), clamped to `3..=20` after flooring.
2082    pub fn set_autocomplete_max_visible(&mut self, max_visible: f64) {
2083        self.set_global_field(
2084            "autocompleteMaxVisible",
2085            js_clamped_floored_number(max_visible, 3.0, 20.0),
2086        );
2087    }
2088
2089    // -- Shell / npm ----------------------------------------------------------
2090
2091    /// `shellPath` with `~` expansion; empty string returned verbatim.
2092    #[must_use]
2093    pub fn get_shell_path(&self) -> Option<String> {
2094        normalize_optional_path(self.settings.get("shellPath"))
2095    }
2096
2097    /// Set `shellPath`; `None` removes the key (global).
2098    pub fn set_shell_path(&mut self, path: Option<String>) {
2099        self.set_global_optional_field("shellPath", path.map(Value::String));
2100    }
2101
2102    /// `shellCommandPrefix` from merged settings.
2103    #[must_use]
2104    pub fn get_shell_command_prefix(&self) -> Option<String> {
2105        string_field(&self.settings, "shellCommandPrefix")
2106    }
2107
2108    /// Set `shellCommandPrefix`; `None` removes the key (global).
2109    pub fn set_shell_command_prefix(&mut self, prefix: Option<String>) {
2110        self.set_global_optional_field("shellCommandPrefix", prefix.map(Value::String));
2111    }
2112
2113    /// `npmCommand` argv from merged settings.
2114    #[must_use]
2115    pub fn get_npm_command(&self) -> Option<Vec<String>> {
2116        string_array(self.settings.get("npmCommand"))
2117    }
2118
2119    /// Set `npmCommand`; `None` removes the key (global).
2120    pub fn set_npm_command(&mut self, command: Option<Vec<String>>) {
2121        self.set_global_optional_field(
2122            "npmCommand",
2123            command.map(|command| Value::Array(command.into_iter().map(Value::String).collect())),
2124        );
2125    }
2126
2127    // -- Trust / telemetry ----------------------------------------------------
2128
2129    /// `defaultProjectTrust` from the **global** settings only.
2130    #[must_use]
2131    pub fn get_default_project_trust(&self) -> DefaultProjectTrust {
2132        DefaultProjectTrust::parse(
2133            self.global_settings
2134                .get("defaultProjectTrust")
2135                .and_then(Value::as_str),
2136        )
2137    }
2138
2139    /// Set `defaultProjectTrust` (global).
2140    pub fn set_default_project_trust(&mut self, default_project_trust: DefaultProjectTrust) {
2141        self.set_global_field(
2142            "defaultProjectTrust",
2143            Value::String(default_project_trust.as_str().to_owned()),
2144        );
2145    }
2146
2147    /// `collapseChangelog` (default: false).
2148    #[must_use]
2149    pub fn get_collapse_changelog(&self) -> bool {
2150        self.merged_bool("collapseChangelog").unwrap_or(false)
2151    }
2152
2153    /// Set `collapseChangelog` (global).
2154    pub fn set_collapse_changelog(&mut self, collapse: bool) {
2155        self.set_global_field("collapseChangelog", Value::Bool(collapse));
2156    }
2157
2158    /// `enableInstallTelemetry` (default: true).
2159    #[must_use]
2160    pub fn get_enable_install_telemetry(&self) -> bool {
2161        self.merged_bool("enableInstallTelemetry").unwrap_or(true)
2162    }
2163
2164    /// Set `enableInstallTelemetry` (global).
2165    pub fn set_enable_install_telemetry(&mut self, enabled: bool) {
2166        self.set_global_field("enableInstallTelemetry", Value::Bool(enabled));
2167    }
2168
2169    /// `enableAnalytics` (default: false).
2170    #[must_use]
2171    pub fn get_enable_analytics(&self) -> bool {
2172        self.merged_bool("enableAnalytics").unwrap_or(false)
2173    }
2174
2175    /// `trackingId` from merged settings.
2176    #[must_use]
2177    pub fn get_tracking_id(&self) -> Option<String> {
2178        string_field(&self.settings, "trackingId")
2179    }
2180
2181    /// Set analytics opt-in; generates a `UUIDv4` tracking id on first opt-in.
2182    pub fn set_enable_analytics(&mut self, enabled: bool) {
2183        self.global_settings
2184            .insert("enableAnalytics".to_owned(), Value::Bool(enabled));
2185        self.modified_fields.insert("enableAnalytics".to_owned());
2186        let has_tracking_id = self
2187            .global_settings
2188            .get("trackingId")
2189            .is_some_and(js_truthy);
2190        if enabled && !has_tracking_id {
2191            self.global_settings.insert(
2192                "trackingId".to_owned(),
2193                Value::String(Uuid::new_v4().to_string()),
2194            );
2195            self.modified_fields.insert("trackingId".to_owned());
2196        }
2197        self.save();
2198    }
2199
2200    // -- Resources ------------------------------------------------------------
2201
2202    /// `packages` from merged settings.
2203    #[must_use]
2204    pub fn get_packages(&self) -> Vec<PackageSource> {
2205        self.settings
2206            .get("packages")
2207            .and_then(Value::as_array)
2208            .map(|items| items.iter().filter_map(PackageSource::from_value).collect())
2209            .unwrap_or_default()
2210    }
2211
2212    /// Set `packages` (global).
2213    pub fn set_packages(&mut self, packages: &[PackageSource]) {
2214        self.set_global_field(
2215            "packages",
2216            Value::Array(packages.iter().map(PackageSource::to_value).collect()),
2217        );
2218    }
2219
2220    /// Set `packages` in the project file.
2221    ///
2222    /// # Errors
2223    ///
2224    /// Returns [`SettingsManagerError::ProjectNotTrusted`] when untrusted.
2225    pub fn set_project_packages(
2226        &mut self,
2227        packages: &[PackageSource],
2228    ) -> Result<(), SettingsManagerError> {
2229        self.update_project_settings("packages", |settings| {
2230            settings.insert(
2231                "packages".to_owned(),
2232                Value::Array(packages.iter().map(PackageSource::to_value).collect()),
2233            );
2234        })
2235    }
2236
2237    /// `extensions` resource paths from merged settings.
2238    #[must_use]
2239    pub fn get_extension_paths(&self) -> Vec<String> {
2240        self.merged_string_array("extensions")
2241    }
2242
2243    /// Set `extensions` resource paths (global).
2244    pub fn set_extension_paths(&mut self, paths: Vec<String>) {
2245        self.set_global_field(
2246            "extensions",
2247            Value::Array(paths.into_iter().map(Value::String).collect()),
2248        );
2249    }
2250
2251    /// Set `extensions` resource paths in the project file.
2252    ///
2253    /// # Errors
2254    ///
2255    /// Returns [`SettingsManagerError::ProjectNotTrusted`] when untrusted.
2256    pub fn set_project_extension_paths(
2257        &mut self,
2258        paths: Vec<String>,
2259    ) -> Result<(), SettingsManagerError> {
2260        self.update_project_settings("extensions", |settings| {
2261            settings.insert(
2262                "extensions".to_owned(),
2263                Value::Array(paths.into_iter().map(Value::String).collect()),
2264            );
2265        })
2266    }
2267
2268    /// `skills` resource paths from merged settings.
2269    #[must_use]
2270    pub fn get_skill_paths(&self) -> Vec<String> {
2271        self.merged_string_array("skills")
2272    }
2273
2274    /// Set `skills` resource paths (global).
2275    pub fn set_skill_paths(&mut self, paths: Vec<String>) {
2276        self.set_global_field(
2277            "skills",
2278            Value::Array(paths.into_iter().map(Value::String).collect()),
2279        );
2280    }
2281
2282    /// Set `skills` resource paths in the project file.
2283    ///
2284    /// # Errors
2285    ///
2286    /// Returns [`SettingsManagerError::ProjectNotTrusted`] when untrusted.
2287    pub fn set_project_skill_paths(
2288        &mut self,
2289        paths: Vec<String>,
2290    ) -> Result<(), SettingsManagerError> {
2291        self.update_project_settings("skills", |settings| {
2292            settings.insert(
2293                "skills".to_owned(),
2294                Value::Array(paths.into_iter().map(Value::String).collect()),
2295            );
2296        })
2297    }
2298
2299    /// `prompts` resource paths from merged settings.
2300    #[must_use]
2301    pub fn get_prompt_template_paths(&self) -> Vec<String> {
2302        self.merged_string_array("prompts")
2303    }
2304
2305    /// Set `prompts` resource paths (global).
2306    pub fn set_prompt_template_paths(&mut self, paths: Vec<String>) {
2307        self.set_global_field(
2308            "prompts",
2309            Value::Array(paths.into_iter().map(Value::String).collect()),
2310        );
2311    }
2312
2313    /// Set `prompts` resource paths in the project file.
2314    ///
2315    /// # Errors
2316    ///
2317    /// Returns [`SettingsManagerError::ProjectNotTrusted`] when untrusted.
2318    pub fn set_project_prompt_template_paths(
2319        &mut self,
2320        paths: Vec<String>,
2321    ) -> Result<(), SettingsManagerError> {
2322        self.update_project_settings("prompts", |settings| {
2323            settings.insert(
2324                "prompts".to_owned(),
2325                Value::Array(paths.into_iter().map(Value::String).collect()),
2326            );
2327        })
2328    }
2329
2330    /// `enableSkillCommands` (default: true).
2331    #[must_use]
2332    pub fn get_enable_skill_commands(&self) -> bool {
2333        self.merged_bool("enableSkillCommands").unwrap_or(true)
2334    }
2335
2336    /// Set `enableSkillCommands` (global).
2337    pub fn set_enable_skill_commands(&mut self, enabled: bool) {
2338        self.set_global_field("enableSkillCommands", Value::Bool(enabled));
2339    }
2340
2341    // -- Terminal / images ----------------------------------------------------
2342
2343    /// `terminal.showImages` (default: true).
2344    #[must_use]
2345    pub fn get_show_images(&self) -> bool {
2346        self.merged_nested_bool("terminal", "showImages")
2347            .unwrap_or(true)
2348    }
2349
2350    /// Set `terminal.showImages` (global, nested merge).
2351    pub fn set_show_images(&mut self, show: bool) {
2352        self.set_global_nested_field("terminal", "showImages", Value::Bool(show));
2353    }
2354
2355    /// `terminal.imageWidthCells` (default: 60; floored, min 1).
2356    #[must_use]
2357    pub fn get_image_width_cells(&self) -> u64 {
2358        let value = self
2359            .settings
2360            .get("terminal")
2361            .and_then(Value::as_object)
2362            .and_then(|terminal| terminal.get("imageWidthCells"));
2363        let Some(number) = value.and_then(Value::as_f64) else {
2364            return DEFAULT_IMAGE_WIDTH_CELLS;
2365        };
2366        if !number.is_finite() {
2367            return DEFAULT_IMAGE_WIDTH_CELLS;
2368        }
2369        floor_max_to_u64(number, 1)
2370    }
2371
2372    /// Set `terminal.imageWidthCells` (global, nested merge); floored, min 1.
2373    pub fn set_image_width_cells(&mut self, width: f64) {
2374        self.set_global_nested_field(
2375            "terminal",
2376            "imageWidthCells",
2377            js_min_floored_number(width, 1.0),
2378        );
2379    }
2380
2381    /// `terminal.clearOnShrink`; falls back to `PI_CLEAR_ON_SHRINK === "1"`.
2382    #[must_use]
2383    pub fn get_clear_on_shrink(&self) -> bool {
2384        self.merged_nested_bool("terminal", "clearOnShrink")
2385            .unwrap_or_else(|| env_flag("PI_CLEAR_ON_SHRINK"))
2386    }
2387
2388    /// Set `terminal.clearOnShrink` (global, nested merge).
2389    pub fn set_clear_on_shrink(&mut self, enabled: bool) {
2390        self.set_global_nested_field("terminal", "clearOnShrink", Value::Bool(enabled));
2391    }
2392
2393    /// `terminal.showTerminalProgress` (default: false).
2394    #[must_use]
2395    pub fn get_show_terminal_progress(&self) -> bool {
2396        self.merged_nested_bool("terminal", "showTerminalProgress")
2397            .unwrap_or(false)
2398    }
2399
2400    /// Set `terminal.showTerminalProgress` (global, nested merge).
2401    pub fn set_show_terminal_progress(&mut self, enabled: bool) {
2402        self.set_global_nested_field("terminal", "showTerminalProgress", Value::Bool(enabled));
2403    }
2404
2405    /// `images.autoResize` (default: true).
2406    #[must_use]
2407    pub fn get_image_auto_resize(&self) -> bool {
2408        self.merged_nested_bool("images", "autoResize")
2409            .unwrap_or(true)
2410    }
2411
2412    /// Set `images.autoResize` (global, nested merge).
2413    pub fn set_image_auto_resize(&mut self, enabled: bool) {
2414        self.set_global_nested_field("images", "autoResize", Value::Bool(enabled));
2415    }
2416
2417    /// `images.blockImages` (default: false).
2418    #[must_use]
2419    pub fn get_block_images(&self) -> bool {
2420        self.merged_nested_bool("images", "blockImages")
2421            .unwrap_or(false)
2422    }
2423
2424    /// Set `images.blockImages` (global, nested merge).
2425    pub fn set_block_images(&mut self, blocked: bool) {
2426        self.set_global_nested_field("images", "blockImages", Value::Bool(blocked));
2427    }
2428
2429    // -- Markdown / warnings --------------------------------------------------
2430
2431    /// `markdown.codeBlockIndent` (default: two spaces).
2432    #[must_use]
2433    pub fn get_code_block_indent(&self) -> String {
2434        self.settings
2435            .get("markdown")
2436            .and_then(Value::as_object)
2437            .and_then(|markdown| markdown.get("codeBlockIndent"))
2438            .and_then(Value::as_str)
2439            .map_or_else(|| DEFAULT_CODE_BLOCK_INDENT.to_owned(), str::to_owned)
2440    }
2441
2442    /// `warnings` object (empty when absent).
2443    #[must_use]
2444    pub fn get_warnings(&self) -> WarningSettings {
2445        nested_field(&self.settings, "warnings", WarningSettings::from_map).unwrap_or_default()
2446    }
2447
2448    /// Set `warnings` (global, full overlay).
2449    pub fn set_warnings(&mut self, warnings: &WarningSettings) {
2450        self.set_global_field("warnings", Value::Object(warnings.to_map()));
2451    }
2452}
2453
2454// ---------------------------------------------------------------------------
2455// Helpers: parse, merge, migrate, wire conversions
2456// ---------------------------------------------------------------------------
2457
2458fn path_to_string(path: &Path) -> String {
2459    path.to_string_lossy().into_owned()
2460}
2461
2462fn env_flag(name: &str) -> bool {
2463    env::var(name).is_ok_and(|value| value == "1")
2464}
2465
2466fn string_field(map: &Map<String, Value>, key: &str) -> Option<String> {
2467    map.get(key).and_then(Value::as_str).map(str::to_owned)
2468}
2469
2470fn bool_field(map: &Map<String, Value>, key: &str) -> Option<bool> {
2471    map.get(key).and_then(Value::as_bool)
2472}
2473
2474fn nested_field<T>(
2475    map: &Map<String, Value>,
2476    key: &str,
2477    from: impl FnOnce(&Map<String, Value>) -> T,
2478) -> Option<T> {
2479    map.get(key).and_then(Value::as_object).map(from)
2480}
2481
2482fn string_array(value: Option<&Value>) -> Option<Vec<String>> {
2483    value.and_then(Value::as_array).map(|items| {
2484        items
2485            .iter()
2486            .filter_map(|item| item.as_str().map(str::to_owned))
2487            .collect()
2488    })
2489}
2490
2491fn unknown_fields(map: &Map<String, Value>, known: &[&str]) -> Map<String, Value> {
2492    map.iter()
2493        .filter(|(key, _)| !known.contains(&key.as_str()))
2494        .map(|(key, value)| (key.clone(), value.clone()))
2495        .collect()
2496}
2497
2498fn insert_opt_value(map: &mut Map<String, Value>, key: &str, value: Option<Value>) {
2499    if let Some(value) = value {
2500        map.insert(key.to_owned(), value);
2501    }
2502}
2503
2504fn insert_opt_string(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
2505    insert_opt_value(map, key, value.map(|text| Value::String(text.to_owned())));
2506}
2507
2508fn insert_opt_bool(map: &mut Map<String, Value>, key: &str, value: Option<bool>) {
2509    insert_opt_value(map, key, value.map(Value::Bool));
2510}
2511
2512fn insert_opt_u64(map: &mut Map<String, Value>, key: &str, value: Option<u64>) {
2513    insert_opt_value(map, key, value.map(Value::from));
2514}
2515
2516fn insert_opt_strings(map: &mut Map<String, Value>, key: &str, values: Option<&[String]>) {
2517    insert_opt_value(
2518        map,
2519        key,
2520        values.map(|values| Value::Array(values.iter().cloned().map(Value::String).collect())),
2521    );
2522}
2523
2524fn number_to_u64(value: Option<&Value>) -> Option<u64> {
2525    let value = value?;
2526    if let Some(unsigned) = value.as_u64() {
2527        return Some(unsigned);
2528    }
2529    let number = value.as_f64()?;
2530    finite_floor_to_u64(number)
2531}
2532
2533fn js_number_eq(lhs: f64, rhs: f64) -> bool {
2534    lhs.partial_cmp(&rhs) == Some(std::cmp::Ordering::Equal)
2535}
2536
2537fn js_truthy(value: &Value) -> bool {
2538    match value {
2539        Value::Null => false,
2540        Value::Bool(flag) => *flag,
2541        Value::Number(number) => number.as_f64().is_some_and(|n| !js_number_eq(n, 0.0)),
2542        Value::String(text) => !text.is_empty(),
2543        Value::Array(_) | Value::Object(_) => true,
2544    }
2545}
2546
2547fn js_number_to_string(value: f64) -> String {
2548    if value.is_nan() {
2549        return "NaN".to_owned();
2550    }
2551    if value.is_infinite() {
2552        return if value.is_sign_positive() {
2553            "Infinity".to_owned()
2554        } else {
2555            "-Infinity".to_owned()
2556        };
2557    }
2558    if js_number_eq(value, 0.0) {
2559        return "0".to_owned();
2560    }
2561    format!("{value}")
2562}
2563
2564fn js_value_to_string(value: &Value) -> String {
2565    match value {
2566        Value::Null => "null".to_owned(),
2567        Value::Bool(flag) => flag.to_string(),
2568        Value::Number(number) => {
2569            if let Some(signed) = number.as_i64() {
2570                signed.to_string()
2571            } else if let Some(unsigned) = number.as_u64() {
2572                unsigned.to_string()
2573            } else {
2574                number
2575                    .as_f64()
2576                    .map_or_else(|| "0".to_owned(), js_number_to_string)
2577            }
2578        }
2579        Value::String(text) => text.clone(),
2580        Value::Array(items) => items
2581            .iter()
2582            .map(|item| match item {
2583                Value::Null => String::new(),
2584                other => js_value_to_string(other),
2585            })
2586            .collect::<Vec<_>>()
2587            .join(","),
2588        Value::Object(_) => "[object Object]".to_owned(),
2589    }
2590}
2591
2592/// Floor `value` and encode as a JSON number without float→int casts.
2593///
2594/// Non-finite inputs become JSON `null` (matching `JSON.stringify(NaN|±Infinity)`).
2595fn json_floored_number(value: f64) -> Value {
2596    let floored = value.floor();
2597    if !floored.is_finite() {
2598        return Value::Null;
2599    }
2600    if floored >= 0.0 {
2601        match finite_floor_to_u64(floored) {
2602            Some(unsigned) => Value::from(unsigned),
2603            None => Value::Null,
2604        }
2605    } else {
2606        match finite_floor_to_i64(floored) {
2607            Some(signed) => Value::from(signed),
2608            None => Value::Null,
2609        }
2610    }
2611}
2612
2613fn js_clamped_floored_number(value: f64, min: f64, max: f64) -> Value {
2614    let floored = value.floor();
2615    if floored.is_nan() {
2616        return Value::Null;
2617    }
2618    // NaN already filtered; clamp is safe.
2619    let clamped = floored.clamp(min, max);
2620    json_floored_number(clamped)
2621}
2622
2623fn js_min_floored_number(value: f64, min: f64) -> Value {
2624    let floored = value.floor();
2625    if floored.is_nan() {
2626        return Value::Null;
2627    }
2628    if !floored.is_finite() {
2629        // +Inf → stringify null; -Inf → min
2630        return if floored.is_sign_positive() {
2631            Value::Null
2632        } else {
2633            json_floored_number(min)
2634        };
2635    }
2636    json_floored_number(floored.max(min))
2637}
2638
2639fn floor_max_to_u64(value: f64, min: u64) -> u64 {
2640    let Ok(min_f) = min.to_string().parse::<f64>() else {
2641        return min;
2642    };
2643    let floored = value.floor().max(min_f);
2644    match finite_floor_to_u64(floored) {
2645        Some(result) => result,
2646        None => min,
2647    }
2648}
2649
2650fn parse_timeout_ms(value: &Value) -> Option<u64> {
2651    match value {
2652        Value::String(text) => {
2653            let trimmed = text.trim();
2654            if trimmed.eq_ignore_ascii_case("disabled") {
2655                return Some(0);
2656            }
2657            if trimmed.is_empty() {
2658                return None;
2659            }
2660            js_number_from_string(trimmed).and_then(finite_floor_to_u64)
2661        }
2662        Value::Number(number) => number.as_f64().and_then(finite_floor_to_u64),
2663        _ => None,
2664    }
2665}
2666
2667fn parse_timeout_setting(
2668    value: Option<&Value>,
2669    setting: &'static str,
2670) -> Result<Option<u64>, SettingsManagerError> {
2671    match value {
2672        None => Ok(None),
2673        Some(value) => match parse_timeout_ms(value) {
2674            Some(ms) => Ok(Some(ms)),
2675            None => Err(SettingsManagerError::InvalidSetting {
2676                setting,
2677                value: js_value_to_string(value),
2678            }),
2679        },
2680    }
2681}
2682
2683/// Floor a non-negative finite `f64` to `u64` via decimal text (no float cast).
2684fn finite_floor_to_u64(value: f64) -> Option<u64> {
2685    if !value.is_finite() || value < 0.0 {
2686        return None;
2687    }
2688    let floored = value.floor();
2689    if floored >= U64_MAX_F64 {
2690        return Some(u64::MAX);
2691    }
2692    // Integer-valued f64 in the safe range formats as a plain digit string.
2693    let text = format!("{floored:.0}");
2694    text.parse::<u64>().ok()
2695}
2696
2697/// Floor a non-positive finite `f64` to `i64` via decimal text (no float cast).
2698fn finite_floor_to_i64(value: f64) -> Option<i64> {
2699    if !value.is_finite() {
2700        return None;
2701    }
2702    let floored = value.floor();
2703    // i64::MIN is exactly -2^63, representable in f64.
2704    if floored < I64_MIN_F64 {
2705        return Some(i64::MIN);
2706    }
2707    if floored > 0.0 {
2708        // Positive values use the u64 path elsewhere; keep signed path defensive.
2709        let text = format!("{floored:.0}");
2710        return text.parse::<i64>().ok();
2711    }
2712    let text = format!("{floored:.0}");
2713    text.parse::<i64>().ok()
2714}
2715
2716fn js_number_from_string(text: &str) -> Option<f64> {
2717    if text.is_empty() {
2718        return Some(0.0);
2719    }
2720    let (negative, rest) = match text.strip_prefix('-') {
2721        Some(rest) => (true, rest),
2722        None => (false, text.strip_prefix('+').unwrap_or(text)),
2723    };
2724    let magnitude = if rest == "Infinity" {
2725        Some(f64::INFINITY)
2726    } else if let Some(digits) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
2727        parse_radix(digits, 16)
2728    } else if let Some(digits) = rest.strip_prefix("0o").or_else(|| rest.strip_prefix("0O")) {
2729        parse_radix(digits, 8)
2730    } else if let Some(digits) = rest.strip_prefix("0b").or_else(|| rest.strip_prefix("0B")) {
2731        parse_radix(digits, 2)
2732    } else if is_js_decimal(rest) {
2733        rest.parse::<f64>().ok()
2734    } else {
2735        None
2736    }?;
2737    Some(if negative { -magnitude } else { magnitude })
2738}
2739
2740/// Parse a radix integer string into `f64` using only `From<u32>` (no casts).
2741fn parse_radix(digits: &str, radix: u32) -> Option<f64> {
2742    if digits.is_empty() {
2743        return None;
2744    }
2745    let mut value = 0.0_f64;
2746    let radix_f = f64::from(radix);
2747    for ch in digits.chars() {
2748        let digit = ch.to_digit(radix)?;
2749        value = value.mul_add(radix_f, f64::from(digit));
2750    }
2751    Some(value)
2752}
2753
2754fn is_js_decimal(text: &str) -> bool {
2755    let (mantissa, exponent) = match text.find(['e', 'E']) {
2756        Some(index) => (&text[..index], Some(&text[index + 1..])),
2757        None => (text, None),
2758    };
2759    if let Some(exp) = exponent {
2760        let exp_digits = exp.strip_prefix(['+', '-']).unwrap_or(exp);
2761        if exp_digits.is_empty() || !exp_digits.bytes().all(|b| b.is_ascii_digit()) {
2762            return false;
2763        }
2764    }
2765    let mut seen_digit = false;
2766    let mut seen_dot = false;
2767    for ch in mantissa.chars() {
2768        if ch == '.' {
2769            if seen_dot {
2770                return false;
2771            }
2772            seen_dot = true;
2773        } else if ch.is_ascii_digit() {
2774            seen_digit = true;
2775        } else {
2776            return false;
2777        }
2778    }
2779    seen_digit
2780}
2781
2782fn parse_thinking_level(value: Option<&Value>) -> Option<ModelThinkingLevel> {
2783    match value?.as_str()? {
2784        "off" => Some(ModelThinkingLevel::Off),
2785        "minimal" => Some(ModelThinkingLevel::Minimal),
2786        "low" => Some(ModelThinkingLevel::Low),
2787        "medium" => Some(ModelThinkingLevel::Medium),
2788        "high" => Some(ModelThinkingLevel::High),
2789        "xhigh" => Some(ModelThinkingLevel::Xhigh),
2790        "max" => Some(ModelThinkingLevel::Max),
2791        _ => None,
2792    }
2793}
2794
2795fn thinking_level_value(level: ModelThinkingLevel) -> Value {
2796    Value::String(
2797        match level {
2798            ModelThinkingLevel::Off => "off",
2799            ModelThinkingLevel::Minimal => "minimal",
2800            ModelThinkingLevel::Low => "low",
2801            ModelThinkingLevel::Medium => "medium",
2802            ModelThinkingLevel::High => "high",
2803            ModelThinkingLevel::Xhigh => "xhigh",
2804            ModelThinkingLevel::Max => "max",
2805        }
2806        .to_owned(),
2807    )
2808}
2809
2810fn parse_transport(value: Option<&Value>) -> Option<Transport> {
2811    match value?.as_str()? {
2812        "sse" => Some(Transport::Sse),
2813        "websocket" => Some(Transport::Websocket),
2814        "websocket-cached" => Some(Transport::WebsocketCached),
2815        "auto" => Some(Transport::Auto),
2816        _ => None,
2817    }
2818}
2819
2820fn transport_value(transport: Transport) -> Value {
2821    Value::String(
2822        match transport {
2823            Transport::Sse => "sse",
2824            Transport::Websocket => "websocket",
2825            Transport::WebsocketCached => "websocket-cached",
2826            Transport::Auto => "auto",
2827        }
2828        .to_owned(),
2829    )
2830}
2831
2832fn parse_queue_mode(value: Option<&Value>) -> Option<QueueMode> {
2833    match value?.as_str()? {
2834        "all" => Some(QueueMode::All),
2835        "one-at-a-time" => Some(QueueMode::OneAtATime),
2836        _ => None,
2837    }
2838}
2839
2840fn queue_mode_value(mode: QueueMode) -> Value {
2841    Value::String(
2842        match mode {
2843            QueueMode::All => "all",
2844            QueueMode::OneAtATime => "one-at-a-time",
2845        }
2846        .to_owned(),
2847    )
2848}
2849
2850fn parse_default_project_trust(value: Option<&Value>) -> Option<DefaultProjectTrust> {
2851    match value?.as_str()? {
2852        "ask" => Some(DefaultProjectTrust::Ask),
2853        "always" => Some(DefaultProjectTrust::Always),
2854        "never" => Some(DefaultProjectTrust::Never),
2855        _ => None,
2856    }
2857}
2858
2859fn parse_output_pad(value: Option<&Value>) -> Option<OutputPad> {
2860    let number = value?.as_f64()?;
2861    if js_number_eq(number, 0.0) {
2862        Some(OutputPad::Zero)
2863    } else if js_number_eq(number, 1.0) {
2864        Some(OutputPad::One)
2865    } else {
2866        None
2867    }
2868}
2869
2870fn normalize_optional_path(value: Option<&Value>) -> Option<String> {
2871    let raw = value?.as_str()?;
2872    if raw.is_empty() {
2873        return Some(raw.to_owned());
2874    }
2875    Some(path_to_string(&expand_tilde_path(raw)))
2876}
2877
2878fn parse_settings_text(text: &str) -> Result<Map<String, Value>, String> {
2879    let value: Value = serde_json::from_str(text).map_err(|error| error.to_string())?;
2880    match value {
2881        Value::Object(mut map) => {
2882            migrate_settings(&mut map);
2883            Ok(map)
2884        }
2885        Value::Array(items) => {
2886            // TS spreads a top-level array at each use site; we spread at load.
2887            Ok(spread_array(&items))
2888        }
2889        other => Err(format!(
2890            "Cannot use 'in' operator to search for 'queueMode' in {}",
2891            js_value_to_string(&other)
2892        )),
2893    }
2894}
2895
2896fn spread_array(items: &[Value]) -> Map<String, Value> {
2897    items
2898        .iter()
2899        .enumerate()
2900        .map(|(index, value)| (index.to_string(), value.clone()))
2901        .collect()
2902}
2903
2904/// One-level object merge with array replacement (ports `deepMergeSettings`).
2905fn deep_merge_settings(
2906    base: &Map<String, Value>,
2907    overrides: &Map<String, Value>,
2908) -> Map<String, Value> {
2909    let mut result = base.clone();
2910    for (key, override_value) in overrides {
2911        let merged_value = match (base.get(key), override_value) {
2912            (Some(Value::Object(base_object)), Value::Object(override_object)) => {
2913                let mut merged = base_object.clone();
2914                for (nested_key, nested_value) in override_object {
2915                    merged.insert(nested_key.clone(), nested_value.clone());
2916                }
2917                Value::Object(merged)
2918            }
2919            _ => override_value.clone(),
2920        };
2921        result.insert(key.clone(), merged_value);
2922    }
2923    result
2924}
2925
2926/// Migrate legacy settings keys in the exact TypeScript order.
2927fn migrate_settings(settings: &mut Map<String, Value>) {
2928    // 1. queueMode → steeringMode (only when steeringMode absent).
2929    if settings.contains_key("queueMode")
2930        && !settings.contains_key("steeringMode")
2931        && let Some(value) = settings.remove("queueMode")
2932    {
2933        settings.insert("steeringMode".to_owned(), value);
2934    }
2935
2936    // 2. websockets boolean → transport (only when transport absent).
2937    if !settings.contains_key("transport")
2938        && let Some(Value::Bool(websockets)) = settings.get("websockets").cloned()
2939    {
2940        settings.remove("websockets");
2941        settings.insert(
2942            "transport".to_owned(),
2943            Value::String(if websockets {
2944                "websocket".to_owned()
2945            } else {
2946                "sse".to_owned()
2947            }),
2948        );
2949    }
2950
2951    // 3. skills object → enableSkillCommands hoist + customDirectories array.
2952    if let Some(Value::Object(skills_object)) = settings.get("skills").cloned() {
2953        if let Some(enable) = skills_object.get("enableSkillCommands")
2954            && !settings.contains_key("enableSkillCommands")
2955        {
2956            settings.insert("enableSkillCommands".to_owned(), enable.clone());
2957        }
2958        match skills_object.get("customDirectories") {
2959            Some(Value::Array(dirs)) if !dirs.is_empty() => {
2960                settings.insert("skills".to_owned(), Value::Array(dirs.clone()));
2961            }
2962            _ => {
2963                settings.remove("skills");
2964            }
2965        }
2966    }
2967
2968    // 4. retry.maxDelayMs → retry.provider.maxRetryDelayMs.
2969    if let Some(Value::Object(retry_object)) = settings.get("retry").cloned() {
2970        let mut retry_object = retry_object;
2971        let max_delay = retry_object
2972            .get("maxDelayMs")
2973            .filter(|value| value.is_number())
2974            .cloned();
2975        if let Some(max_delay_value) = max_delay {
2976            let provider_object_like: Option<Map<String, Value>> =
2977                match retry_object.get("provider") {
2978                    Some(Value::Object(map)) => Some(map.clone()),
2979                    Some(Value::Array(items)) => Some(spread_array(items)),
2980                    _ => None,
2981                };
2982            let current_max = provider_object_like
2983                .as_ref()
2984                .and_then(|provider| provider.get("maxRetryDelayMs"));
2985            let needs_migration = matches!(
2986                (provider_object_like.is_some(), current_max),
2987                (false, _) | (true, None | Some(Value::Null))
2988            );
2989            if needs_migration {
2990                let mut provider = provider_object_like.unwrap_or_default();
2991                provider.insert("maxRetryDelayMs".to_owned(), max_delay_value);
2992                retry_object.insert("provider".to_owned(), Value::Object(provider));
2993            }
2994        }
2995        retry_object.remove("maxDelayMs");
2996        settings.insert("retry".to_owned(), Value::Object(retry_object));
2997    }
2998}
2999
3000// ---------------------------------------------------------------------------
3001// Tests
3002// ---------------------------------------------------------------------------
3003
3004#[cfg(test)]
3005mod tests {
3006    use super::*;
3007    use std::time::{SystemTime, UNIX_EPOCH};
3008
3009    type TestResult = Result<(), String>;
3010
3011    fn unique_temp_dir(label: &str) -> Result<PathBuf, String> {
3012        let nanos = SystemTime::now()
3013            .duration_since(UNIX_EPOCH)
3014            .map_err(|error| error.to_string())?
3015            .as_nanos();
3016        let dir = env::temp_dir().join(format!("pi-settings-{label}-{nanos}"));
3017        fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
3018        Ok(dir)
3019    }
3020
3021    fn write_settings_file(path: &Path, contents: &str) -> Result<(), String> {
3022        if let Some(parent) = path.parent() {
3023            fs::create_dir_all(parent).map_err(|error| error.to_string())?;
3024        }
3025        fs::write(path, contents).map_err(|error| error.to_string())
3026    }
3027
3028    fn read_text(path: &Path) -> Result<String, String> {
3029        fs::read_to_string(path).map_err(|error| error.to_string())
3030    }
3031
3032    fn parse_file(path: &Path) -> Result<Value, String> {
3033        let text = read_text(path)?;
3034        serde_json::from_str(&text).map_err(|error| error.to_string())
3035    }
3036
3037    fn make_dirs(label: &str) -> Result<(PathBuf, PathBuf, PathBuf), String> {
3038        let root = unique_temp_dir(label)?;
3039        let agent = root.join("agent");
3040        let project = root.join("project");
3041        fs::create_dir_all(&agent).map_err(|error| error.to_string())?;
3042        fs::create_dir_all(&project).map_err(|error| error.to_string())?;
3043        Ok((root, agent, project))
3044    }
3045
3046    fn create_manager(project: &Path, agent: &Path, trusted: bool) -> SettingsManager {
3047        SettingsManager::create(
3048            project,
3049            Some(agent),
3050            SettingsManagerCreateOptions::default().project_trusted(trusted),
3051        )
3052    }
3053
3054    #[test]
3055    fn defaults_on_empty_settings() -> TestResult {
3056        let manager = SettingsManager::in_memory(
3057            &Settings::default(),
3058            SettingsManagerCreateOptions::default(),
3059        );
3060        assert_eq!(manager.get_transport(), Transport::Auto);
3061        assert_eq!(manager.get_steering_mode(), QueueMode::OneAtATime);
3062        assert_eq!(manager.get_follow_up_mode(), QueueMode::OneAtATime);
3063        assert!(manager.get_compaction_enabled());
3064        assert_eq!(manager.get_compaction_reserve_tokens(), 16384);
3065        assert_eq!(manager.get_compaction_keep_recent_tokens(), 20000);
3066        let branch = manager.get_branch_summary_settings();
3067        assert_eq!(branch.reserve_tokens, 16384);
3068        assert!(!branch.skip_prompt);
3069        let retry = manager.get_retry_settings();
3070        assert!(retry.enabled);
3071        assert_eq!(retry.max_retries, 3);
3072        assert_eq!(retry.base_delay_ms, 2000);
3073        assert_eq!(
3074            manager.get_provider_retry_settings().max_retry_delay_ms,
3075            60000
3076        );
3077        let idle = manager
3078            .get_http_idle_timeout_ms()
3079            .map_err(|error| error.to_string())?;
3080        assert_eq!(idle, 300_000);
3081        assert_eq!(manager.get_image_width_cells(), 60);
3082        assert_eq!(manager.get_editor_padding_x(), 0);
3083        assert_eq!(manager.get_output_pad(), OutputPad::One);
3084        assert_eq!(manager.get_autocomplete_max_visible(), 5);
3085        assert_eq!(manager.get_code_block_indent(), "  ");
3086        assert_eq!(manager.get_tree_filter_mode(), TreeFilterMode::Default);
3087        assert_eq!(manager.get_double_escape_action(), DoubleEscapeAction::Tree);
3088        assert!(manager.get_enable_install_telemetry());
3089        assert!(!manager.get_enable_analytics());
3090        assert!(manager.get_enable_skill_commands());
3091        assert!(manager.get_show_images());
3092        assert!(manager.get_image_auto_resize());
3093        assert!(!manager.get_block_images());
3094        assert!(!manager.get_show_terminal_progress());
3095        assert!(!manager.get_quiet_startup());
3096        assert!(!manager.get_hide_thinking_block());
3097        assert!(!manager.get_show_cache_miss_notices());
3098        assert!(!manager.get_collapse_changelog());
3099        assert_eq!(
3100            manager.get_default_project_trust(),
3101            DefaultProjectTrust::Ask
3102        );
3103        assert!(manager.get_theme().is_none());
3104        if env::var_os("PI_CLEAR_ON_SHRINK").is_none() {
3105            assert!(!manager.get_clear_on_shrink());
3106        }
3107        if env::var_os("PI_HARDWARE_CURSOR").is_none() {
3108            assert!(!manager.get_show_hardware_cursor());
3109        }
3110        if env::var_os("VISUAL").is_none() && env::var_os("EDITOR").is_none() {
3111            assert_eq!(
3112                manager.get_external_editor_command(),
3113                if cfg!(windows) { "notepad" } else { "nano" }
3114            );
3115        }
3116        Ok(())
3117    }
3118
3119    #[test]
3120    fn hardware_cursor_setting_precedes_environment_fallback() {
3121        let mut manager = SettingsManager::in_memory(
3122            &Settings::default(),
3123            SettingsManagerCreateOptions::default(),
3124        );
3125        assert_eq!(
3126            manager.get_show_hardware_cursor(),
3127            env::var("PI_HARDWARE_CURSOR").is_ok_and(|value| value == "1")
3128        );
3129
3130        manager.set_show_hardware_cursor(false);
3131        assert!(!manager.get_show_hardware_cursor());
3132        manager.set_show_hardware_cursor(true);
3133        assert!(manager.get_show_hardware_cursor());
3134    }
3135
3136    #[test]
3137    fn clamps_and_floor_behaviors() -> TestResult {
3138        let mut manager = SettingsManager::in_memory(
3139            &Settings::default(),
3140            SettingsManagerCreateOptions::default(),
3141        );
3142        manager.set_editor_padding_x(9.7);
3143        assert_eq!(manager.get_editor_padding_x(), 3);
3144        manager.set_editor_padding_x(-2.3);
3145        assert_eq!(manager.get_editor_padding_x(), 0);
3146        manager.set_autocomplete_max_visible(100.0);
3147        assert_eq!(manager.get_autocomplete_max_visible(), 20);
3148        manager.set_autocomplete_max_visible(2.0);
3149        assert_eq!(manager.get_autocomplete_max_visible(), 3);
3150        manager.set_autocomplete_max_visible(7.9);
3151        assert_eq!(manager.get_autocomplete_max_visible(), 7);
3152        manager.set_image_width_cells(0.2);
3153        assert_eq!(manager.get_image_width_cells(), 1);
3154        manager.set_image_width_cells(80.9);
3155        assert_eq!(manager.get_image_width_cells(), 80);
3156        manager
3157            .set_http_idle_timeout_ms(1500.7)
3158            .map_err(|error| error.to_string())?;
3159        let idle = manager
3160            .get_http_idle_timeout_ms()
3161            .map_err(|error| error.to_string())?;
3162        assert_eq!(idle, 1500);
3163        let error = match manager.set_http_idle_timeout_ms(-1.0) {
3164            Ok(()) => return Err("expected negative rejection".into()),
3165            Err(error) => error,
3166        };
3167        assert_eq!(error.to_string(), "Invalid httpIdleTimeoutMs setting: -1");
3168        let error = match manager.set_http_idle_timeout_ms(f64::NAN) {
3169            Ok(()) => return Err("expected nan rejection".into()),
3170            Err(error) => error,
3171        };
3172        assert_eq!(error.to_string(), "Invalid httpIdleTimeoutMs setting: NaN");
3173        let error = match manager.set_http_idle_timeout_ms(f64::INFINITY) {
3174            Ok(()) => return Err("expected inf rejection".into()),
3175            Err(error) => error,
3176        };
3177        assert_eq!(
3178            error.to_string(),
3179            "Invalid httpIdleTimeoutMs setting: Infinity"
3180        );
3181        Ok(())
3182    }
3183
3184    #[test]
3185    fn http_timeout_parsing_and_exact_errors() -> TestResult {
3186        let (_root, agent, project) = make_dirs("timeout-parse")?;
3187        write_settings_file(
3188            &agent.join("settings.json"),
3189            r#"{
3190  "httpIdleTimeoutMs": "disabled",
3191  "websocketConnectTimeoutMs": "5000"
3192}"#,
3193        )?;
3194        let manager = create_manager(&project, &agent, true);
3195        assert_eq!(
3196            manager
3197                .get_http_idle_timeout_ms()
3198                .map_err(|e| e.to_string())?,
3199            0
3200        );
3201        assert_eq!(
3202            manager
3203                .get_web_socket_connect_timeout_ms()
3204                .map_err(|e| e.to_string())?,
3205            Some(5000)
3206        );
3207
3208        write_settings_file(
3209            &agent.join("settings.json"),
3210            r#"{ "websocketConnectTimeoutMs": "abc" }"#,
3211        )?;
3212        let manager = create_manager(&project, &agent, true);
3213        let Err(error) = manager.get_web_socket_connect_timeout_ms() else {
3214            return Err("expected invalid stored timeout".into());
3215        };
3216        assert_eq!(
3217            error.to_string(),
3218            "Invalid websocketConnectTimeoutMs setting: abc"
3219        );
3220        Ok(())
3221    }
3222
3223    #[test]
3224    fn migrations_exact_order_and_conditions() -> TestResult {
3225        let (_root, agent, project) = make_dirs("migrations")?;
3226        write_settings_file(
3227            &agent.join("settings.json"),
3228            r#"{
3229  "queueMode": "all",
3230  "websockets": false,
3231  "skills": {
3232    "enableSkillCommands": false,
3233    "customDirectories": ["/x"]
3234  },
3235  "retry": {
3236    "maxDelayMs": 7000
3237  }
3238}"#,
3239        )?;
3240        let mut manager = create_manager(&project, &agent, true);
3241        assert_eq!(manager.get_steering_mode(), QueueMode::All);
3242        assert_eq!(manager.get_transport(), Transport::Sse);
3243        assert_eq!(manager.get_skill_paths(), vec!["/x".to_owned()]);
3244        assert!(!manager.get_enable_skill_commands());
3245        assert_eq!(
3246            manager.get_provider_retry_settings().max_retry_delay_ms,
3247            7000
3248        );
3249        manager.set_quiet_startup(true);
3250        let value = parse_file(&agent.join("settings.json"))?;
3251        assert_eq!(value["steeringMode"], "all");
3252        assert!(value.get("queueMode").is_none());
3253        assert_eq!(value["transport"], "sse");
3254        assert!(value.get("websockets").is_none());
3255        assert_eq!(
3256            value["skills"],
3257            Value::Array(vec![Value::String("/x".into())])
3258        );
3259        assert_eq!(value["enableSkillCommands"], false);
3260        assert_eq!(value["retry"]["provider"]["maxRetryDelayMs"], 7000);
3261        assert!(value["retry"].get("maxDelayMs").is_none());
3262        assert_eq!(value["quietStartup"], true);
3263
3264        // Both present: queueMode kept; websockets kept.
3265        write_settings_file(
3266            &agent.join("settings.json"),
3267            r#"{
3268  "queueMode": "all",
3269  "steeringMode": "one-at-a-time",
3270  "websockets": true,
3271  "transport": "sse",
3272  "skills": { "customDirectories": [] },
3273  "enableSkillCommands": true,
3274  "retry": { "maxDelayMs": 5000, "provider": { "maxRetryDelayMs": 1000 } }
3275}"#,
3276        )?;
3277        let mut manager = create_manager(&project, &agent, true);
3278        assert_eq!(manager.get_steering_mode(), QueueMode::OneAtATime);
3279        assert_eq!(manager.get_transport(), Transport::Sse);
3280        assert!(manager.get_skill_paths().is_empty());
3281        assert!(manager.get_enable_skill_commands());
3282        assert_eq!(
3283            manager.get_provider_retry_settings().max_retry_delay_ms,
3284            1000
3285        );
3286        manager.set_theme("dark");
3287        let value = parse_file(&agent.join("settings.json"))?;
3288        assert_eq!(value["queueMode"], "all");
3289        assert_eq!(value["websockets"], true);
3290        assert!(value.get("skills").is_none());
3291        assert!(value["retry"].get("maxDelayMs").is_none());
3292        assert_eq!(value["retry"]["provider"]["maxRetryDelayMs"], 1000);
3293
3294        // null maxRetryDelayMs migrates; empty customDirectories deletes skills.
3295        write_settings_file(
3296            &agent.join("settings.json"),
3297            r#"{
3298  "retry": { "maxDelayMs": 5000, "provider": { "maxRetryDelayMs": null } }
3299}"#,
3300        )?;
3301        let manager = create_manager(&project, &agent, true);
3302        assert_eq!(
3303            manager.get_provider_retry_settings().max_retry_delay_ms,
3304            5000
3305        );
3306        Ok(())
3307    }
3308
3309    #[test]
3310    fn unknown_keys_roundtrip_top_and_nested() -> TestResult {
3311        let (_root, agent, project) = make_dirs("unknown")?;
3312        write_settings_file(
3313            &agent.join("settings.json"),
3314            r#"{
3315  "futureThing": { "a": 1 },
3316  "compaction": { "enabled": false, "futureNested": [1, 2] },
3317  "theme": "dark"
3318}"#,
3319        )?;
3320        let mut manager = create_manager(&project, &agent, true);
3321        let view = manager.get_global_settings();
3322        assert!(view.extra.contains_key("futureThing"));
3323        assert_eq!(
3324            view.compaction
3325                .as_ref()
3326                .and_then(|c| c.extra.get("futureNested")),
3327            Some(&Value::Array(vec![Value::from(1), Value::from(2)]))
3328        );
3329        manager.set_quiet_startup(true);
3330        let value = parse_file(&agent.join("settings.json"))?;
3331        assert_eq!(value["futureThing"]["a"], 1);
3332        assert_eq!(value["compaction"]["enabled"], false);
3333        assert_eq!(
3334            value["compaction"]["futureNested"],
3335            Value::Array(vec![Value::from(1), Value::from(2)])
3336        );
3337        assert_eq!(value["theme"], "dark");
3338        assert_eq!(value["quietStartup"], true);
3339        Ok(())
3340    }
3341
3342    #[test]
3343    fn arrays_replace_and_nested_objects_merge_one_level() -> TestResult {
3344        let (_root, agent, project) = make_dirs("arrays")?;
3345        write_settings_file(
3346            &agent.join("settings.json"),
3347            r#"{
3348  "extensions": ["g1", "g2"],
3349  "retry": { "enabled": false, "provider": { "timeoutMs": 5 } },
3350  "terminal": { "showImages": false, "imageWidthCells": 80 }
3351}"#,
3352        )?;
3353        write_settings_file(
3354            &project.join(".pi").join("settings.json"),
3355            r#"{
3356  "extensions": ["p1"],
3357  "retry": { "provider": { "maxRetries": 2 } },
3358  "terminal": { "showImages": true }
3359}"#,
3360        )?;
3361        let manager = create_manager(&project, &agent, true);
3362        assert_eq!(manager.get_extension_paths(), vec!["p1".to_owned()]);
3363        assert!(!manager.get_retry_enabled());
3364        let provider = manager.get_provider_retry_settings();
3365        assert_eq!(provider.max_retries, Some(2));
3366        assert_eq!(provider.timeout_ms, None);
3367        assert!(manager.get_show_images());
3368        assert_eq!(manager.get_image_width_cells(), 80);
3369        Ok(())
3370    }
3371
3372    #[test]
3373    fn parse_error_refuses_save_without_clobber() -> TestResult {
3374        let (_root, agent, project) = make_dirs("parse-error")?;
3375        let bad = "{ not json";
3376        write_settings_file(&agent.join("settings.json"), bad)?;
3377        let mut manager = create_manager(&project, &agent, true);
3378        let errors = manager.drain_errors();
3379        assert_eq!(errors.len(), 1);
3380        assert_eq!(errors[0].scope, SettingsScope::Global);
3381        manager.set_theme("dark");
3382        // In-memory view updates, but file is not clobbered.
3383        assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3384        assert_eq!(read_text(&agent.join("settings.json"))?, bad);
3385
3386        // Recovery: rewrite valid file and reload.
3387        write_settings_file(&agent.join("settings.json"), r#"{ "theme": "light" }"#)?;
3388        manager.reload();
3389        assert!(manager.drain_errors().is_empty());
3390        assert_eq!(manager.get_theme().as_deref(), Some("light"));
3391        manager.set_quiet_startup(true);
3392        let value = parse_file(&agent.join("settings.json"))?;
3393        assert_eq!(value["theme"], "light");
3394        assert_eq!(value["quietStartup"], true);
3395        Ok(())
3396    }
3397
3398    #[test]
3399    fn concurrent_external_changes_are_merged() -> TestResult {
3400        let (_root, agent, project) = make_dirs("external")?;
3401        write_settings_file(
3402            &agent.join("settings.json"),
3403            r#"{
3404  "theme": "dark",
3405  "customUnknown": 1,
3406  "compaction": {
3407    "enabled": false,
3408    "reserveTokens": 999,
3409    "futureNested": "keep"
3410  }
3411}"#,
3412        )?;
3413        let mut manager = create_manager(&project, &agent, true);
3414
3415        // External process rewrite after load.
3416        write_settings_file(
3417            &agent.join("settings.json"),
3418            r#"{
3419  "theme": "light",
3420  "customUnknown": 1,
3421  "externalNew": true,
3422  "compaction": {
3423    "enabled": false,
3424    "reserveTokens": 12345,
3425    "futureNested": "keep"
3426  }
3427}"#,
3428        )?;
3429        manager.set_default_model("gpt-5");
3430        manager.set_compaction_enabled(true);
3431        let value = parse_file(&agent.join("settings.json"))?;
3432        assert_eq!(value["theme"], "light");
3433        assert_eq!(value["externalNew"], true);
3434        assert_eq!(value["customUnknown"], 1);
3435        assert_eq!(value["defaultModel"], "gpt-5");
3436        assert_eq!(value["compaction"]["enabled"], true);
3437        assert_eq!(value["compaction"]["reserveTokens"], 12345);
3438        assert_eq!(value["compaction"]["futureNested"], "keep");
3439        // In-memory snapshot still has the pre-external reserveTokens.
3440        assert_eq!(manager.get_compaction_reserve_tokens(), 999);
3441        Ok(())
3442    }
3443
3444    #[test]
3445    fn project_write_when_untrusted_fails_exact() -> TestResult {
3446        let (_root, agent, project) = make_dirs("trust")?;
3447        write_settings_file(
3448            &project.join(".pi").join("settings.json"),
3449            r#"{ "extensions": ["/secret"] }"#,
3450        )?;
3451        let mut manager = create_manager(&project, &agent, false);
3452        assert!(!manager.is_project_trusted());
3453        assert!(manager.get_extension_paths().is_empty());
3454        assert!(manager.drain_errors().is_empty());
3455        let error = match manager.set_project_skill_paths(vec!["s".into()]) {
3456            Ok(()) => return Err("expected trust error".into()),
3457            Err(error) => error,
3458        };
3459        assert_eq!(
3460            error.to_string(),
3461            "Project is not trusted; refusing to write project settings"
3462        );
3463        assert!(
3464            !project.join(".pi").join("settings.json").exists()
3465                || read_text(&project.join(".pi").join("settings.json"))?
3466                    == r#"{ "extensions": ["/secret"] }"#
3467        );
3468
3469        manager.set_project_trusted(true);
3470        assert_eq!(manager.get_extension_paths(), vec!["/secret".to_owned()]);
3471        manager.set_project_trusted(false);
3472        assert!(manager.get_extension_paths().is_empty());
3473        Ok(())
3474    }
3475
3476    #[test]
3477    fn project_resource_setters_write_project_file_only() -> TestResult {
3478        let (_root, agent, project) = make_dirs("project-set")?;
3479        write_settings_file(
3480            &project.join(".pi").join("settings.json"),
3481            r#"{ "futureKey": 7 }"#,
3482        )?;
3483        let mut manager = create_manager(&project, &agent, true);
3484        manager
3485            .set_project_extension_paths(vec!["./ext".into()])
3486            .map_err(|e| e.to_string())?;
3487        manager
3488            .set_project_skill_paths(vec!["./skill".into()])
3489            .map_err(|e| e.to_string())?;
3490        manager
3491            .set_project_prompt_template_paths(vec!["./prompt".into()])
3492            .map_err(|e| e.to_string())?;
3493        manager
3494            .set_project_theme_paths(vec!["./theme".into()])
3495            .map_err(|e| e.to_string())?;
3496        manager
3497            .set_project_packages(&[PackageSource::Source("npm:pkg".into())])
3498            .map_err(|e| e.to_string())?;
3499
3500        assert!(!agent.join("settings.json").exists());
3501        let value = parse_file(&project.join(".pi").join("settings.json"))?;
3502        assert_eq!(value["futureKey"], 7);
3503        assert_eq!(
3504            value["extensions"],
3505            Value::Array(vec![Value::String("./ext".into())])
3506        );
3507        assert_eq!(
3508            value["skills"],
3509            Value::Array(vec![Value::String("./skill".into())])
3510        );
3511        assert_eq!(
3512            value["prompts"],
3513            Value::Array(vec![Value::String("./prompt".into())])
3514        );
3515        assert_eq!(
3516            value["themes"],
3517            Value::Array(vec![Value::String("./theme".into())])
3518        );
3519        assert_eq!(
3520            value["packages"],
3521            Value::Array(vec![Value::String("npm:pkg".into())])
3522        );
3523        assert_eq!(manager.get_extension_paths(), vec!["./ext".to_owned()]);
3524        assert_eq!(manager.get_skill_paths(), vec!["./skill".to_owned()]);
3525        assert_eq!(
3526            manager.get_prompt_template_paths(),
3527            vec!["./prompt".to_owned()]
3528        );
3529        assert_eq!(manager.get_theme_paths(), vec!["./theme".to_owned()]);
3530        assert_eq!(
3531            manager.get_packages(),
3532            vec![PackageSource::Source("npm:pkg".into())]
3533        );
3534
3535        // Project load error: silent refuse.
3536        write_settings_file(&project.join(".pi").join("settings.json"), "{ bad")?;
3537        let mut manager = create_manager(&project, &agent, true);
3538        let errors = manager.drain_errors();
3539        assert_eq!(errors.len(), 1);
3540        assert_eq!(errors[0].scope, SettingsScope::Project);
3541        manager
3542            .set_project_extension_paths(vec!["x".into()])
3543            .map_err(|e| e.to_string())?;
3544        assert_eq!(
3545            read_text(&project.join(".pi").join("settings.json"))?,
3546            "{ bad"
3547        );
3548        Ok(())
3549    }
3550
3551    #[test]
3552    fn no_trailing_newline_and_pretty_format() -> TestResult {
3553        let (_root, agent, project) = make_dirs("newline")?;
3554        let mut manager = create_manager(&project, &agent, true);
3555        manager.set_theme("dark");
3556        manager.set_quiet_startup(true);
3557        let text = read_text(&agent.join("settings.json"))?;
3558        assert!(
3559            !text.ends_with('\n'),
3560            "must not force trailing newline: {text:?}"
3561        );
3562        let expected = serde_json::to_string_pretty(&serde_json::json!({
3563            "quietStartup": true,
3564            "theme": "dark"
3565        }))
3566        .map_err(|e| e.to_string())?;
3567        assert_eq!(text, expected);
3568        Ok(())
3569    }
3570
3571    #[test]
3572    fn theme_path_hiding() {
3573        let mut manager = SettingsManager::in_memory(
3574            &Settings::default(),
3575            SettingsManagerCreateOptions::default(),
3576        );
3577        manager.set_theme("foo/bar");
3578        assert_eq!(manager.get_theme_setting().as_deref(), Some("foo/bar"));
3579        assert!(manager.get_theme().is_none());
3580        manager.set_theme("plain");
3581        assert_eq!(manager.get_theme().as_deref(), Some("plain"));
3582    }
3583
3584    #[test]
3585    fn analytics_tracking_id_lifecycle() -> TestResult {
3586        let mut manager = SettingsManager::in_memory(
3587            &Settings::default(),
3588            SettingsManagerCreateOptions::default(),
3589        );
3590        assert!(manager.get_tracking_id().is_none());
3591        manager.set_enable_analytics(true);
3592        let first = manager
3593            .get_tracking_id()
3594            .ok_or_else(|| "expected generated tracking id".to_owned())?;
3595        assert!(Uuid::parse_str(&first).is_ok());
3596        manager.set_enable_analytics(false);
3597        manager.set_enable_analytics(true);
3598        assert_eq!(manager.get_tracking_id().as_deref(), Some(first.as_str()));
3599
3600        let seed = Settings {
3601            tracking_id: Some("fixed-id".into()),
3602            enable_analytics: Some(false),
3603            ..Settings::default()
3604        };
3605        let mut manager =
3606            SettingsManager::in_memory(&seed, SettingsManagerCreateOptions::default());
3607        manager.set_enable_analytics(true);
3608        assert_eq!(manager.get_tracking_id().as_deref(), Some("fixed-id"));
3609        Ok(())
3610    }
3611
3612    #[test]
3613    fn default_project_trust_is_global_only() -> TestResult {
3614        let (_root, agent, project) = make_dirs("dpt")?;
3615        write_settings_file(
3616            &project.join(".pi").join("settings.json"),
3617            r#"{ "defaultProjectTrust": "always" }"#,
3618        )?;
3619        let manager = create_manager(&project, &agent, true);
3620        assert_eq!(
3621            manager.get_default_project_trust(),
3622            DefaultProjectTrust::Ask
3623        );
3624
3625        write_settings_file(
3626            &agent.join("settings.json"),
3627            r#"{ "defaultProjectTrust": "always" }"#,
3628        )?;
3629        let manager = create_manager(&project, &agent, true);
3630        assert_eq!(
3631            manager.get_default_project_trust(),
3632            DefaultProjectTrust::Always
3633        );
3634
3635        write_settings_file(
3636            &agent.join("settings.json"),
3637            r#"{ "defaultProjectTrust": "bogus" }"#,
3638        )?;
3639        let manager = create_manager(&project, &agent, true);
3640        assert_eq!(
3641            manager.get_default_project_trust(),
3642            DefaultProjectTrust::Ask
3643        );
3644        Ok(())
3645    }
3646
3647    #[test]
3648    fn session_dir_normalization() -> TestResult {
3649        let (_root, agent, project) = make_dirs("session-dir")?;
3650        write_settings_file(
3651            &agent.join("settings.json"),
3652            r#"{ "sessionDir": "/abs/path" }"#,
3653        )?;
3654        let manager = create_manager(&project, &agent, true);
3655        assert_eq!(manager.get_session_dir().as_deref(), Some("/abs/path"));
3656        write_settings_file(&agent.join("settings.json"), r#"{ "sessionDir": "" }"#)?;
3657        let manager = create_manager(&project, &agent, true);
3658        assert_eq!(manager.get_session_dir().as_deref(), Some(""));
3659        Ok(())
3660    }
3661
3662    #[test]
3663    fn apply_overrides_remerged_on_save() {
3664        let mut manager = SettingsManager::in_memory(
3665            &Settings::default(),
3666            SettingsManagerCreateOptions::default(),
3667        );
3668        let mut overrides = Map::new();
3669        overrides.insert("theme".into(), Value::String("override".into()));
3670        manager.apply_overrides(&overrides);
3671        assert_eq!(manager.get_theme().as_deref(), Some("override"));
3672        manager.set_quiet_startup(true);
3673        // save re-merges global+project, discarding overrides.
3674        assert!(manager.get_theme().is_none());
3675        assert!(manager.get_quiet_startup());
3676    }
3677
3678    #[test]
3679    fn reload_picks_up_external_changes() -> TestResult {
3680        let (_root, agent, project) = make_dirs("reload")?;
3681        write_settings_file(&agent.join("settings.json"), r#"{ "theme": "dark" }"#)?;
3682        let mut manager = create_manager(&project, &agent, true);
3683        assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3684        write_settings_file(&agent.join("settings.json"), r#"{ "theme": "light" }"#)?;
3685        assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3686        manager.reload();
3687        assert_eq!(manager.get_theme().as_deref(), Some("light"));
3688        Ok(())
3689    }
3690
3691    #[test]
3692    fn in_memory_storage_roundtrip() -> TestResult {
3693        let mut storage = InMemorySettingsStorage::new();
3694        storage.with_lock(SettingsScope::Global, &mut |_| {
3695            Ok(Some(r#"{"theme":"dark"}"#.to_owned()))
3696        })?;
3697        let mut manager = SettingsManager::from_storage(
3698            Box::new(storage),
3699            SettingsManagerCreateOptions::default(),
3700        );
3701        assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3702        manager.set_theme("light");
3703        assert_eq!(
3704            manager.get_global_settings().theme.as_deref(),
3705            Some("light")
3706        );
3707        Ok(())
3708    }
3709
3710    #[test]
3711    fn missing_file_load_creates_no_lock_artifact() -> TestResult {
3712        let (root, agent, project) = make_dirs("lock-artifact")?;
3713        let manager = create_manager(&project, &agent, true);
3714        assert!(!agent.join("settings.json.lock").exists());
3715        drop(manager);
3716        assert!(!agent.join("settings.json").exists());
3717        assert!(!agent.join("settings.json.lock").exists());
3718
3719        // Write creates parent dirs for a nested agent path.
3720        let nested_agent = root.join("nested").join("agent");
3721        let mut manager = create_manager(&project, &nested_agent, true);
3722        manager.set_theme("dark");
3723        assert!(nested_agent.join("settings.json").exists());
3724        assert!(!nested_agent.join("settings.json.lock").exists());
3725        Ok(())
3726    }
3727
3728    #[test]
3729    fn output_pad_strict_zero() -> TestResult {
3730        let (_root, agent, project) = make_dirs("output-pad")?;
3731        write_settings_file(&agent.join("settings.json"), r#"{ "outputPad": 0 }"#)?;
3732        let manager = create_manager(&project, &agent, true);
3733        assert_eq!(manager.get_output_pad(), OutputPad::Zero);
3734        write_settings_file(&agent.join("settings.json"), r#"{ "outputPad": 2 }"#)?;
3735        let manager = create_manager(&project, &agent, true);
3736        assert_eq!(manager.get_output_pad(), OutputPad::One);
3737        write_settings_file(&agent.join("settings.json"), r#"{ "outputPad": "0" }"#)?;
3738        let manager = create_manager(&project, &agent, true);
3739        assert_eq!(manager.get_output_pad(), OutputPad::One);
3740        Ok(())
3741    }
3742
3743    #[test]
3744    fn package_sources_typed_roundtrip() {
3745        let mut manager = SettingsManager::in_memory(
3746            &Settings::default(),
3747            SettingsManagerCreateOptions::default(),
3748        );
3749        let packages = vec![
3750            PackageSource::Source("npm:a".into()),
3751            PackageSource::Filtered(PackageSourceFilter {
3752                source: "git:b".into(),
3753                autoload: Some(false),
3754                extensions: Some(vec!["e".into()]),
3755                skills: None,
3756                prompts: None,
3757                themes: None,
3758                extra: {
3759                    let mut extra = Map::new();
3760                    extra.insert("future".into(), Value::from(1));
3761                    extra
3762                },
3763            }),
3764        ];
3765        manager.set_packages(&packages);
3766        assert_eq!(manager.get_packages(), packages);
3767        let view = manager.get_global_settings();
3768        assert_eq!(view.packages, Some(packages));
3769    }
3770
3771    #[test]
3772    fn set_default_model_and_provider_marks_both() -> TestResult {
3773        let (_root, agent, project) = make_dirs("both")?;
3774        let mut manager = create_manager(&project, &agent, true);
3775        manager.set_default_model_and_provider("openai", "gpt-5");
3776        let value = parse_file(&agent.join("settings.json"))?;
3777        assert_eq!(value["defaultProvider"], "openai");
3778        assert_eq!(value["defaultModel"], "gpt-5");
3779        Ok(())
3780    }
3781
3782    #[test]
3783    fn typed_settings_from_map_to_map_preserves_extra() {
3784        let mut map = Map::new();
3785        map.insert("theme".into(), Value::String("dark".into()));
3786        map.insert("future".into(), Value::from(42));
3787        let settings = Settings::from_map(&map);
3788        assert_eq!(settings.theme.as_deref(), Some("dark"));
3789        assert_eq!(settings.extra.get("future"), Some(&Value::from(42)));
3790        let roundtrip = settings.to_map();
3791        assert_eq!(roundtrip.get("theme"), Some(&Value::String("dark".into())));
3792        assert_eq!(roundtrip.get("future"), Some(&Value::from(42)));
3793    }
3794}