Skip to main content

rpi_cli/
settings.rs

1//! `~/.rpi/agent/settings.json` — saved user defaults. Mirrors the slice of
2//! pi's `Settings` interface (`packages/coding-agent/src/core/settings-manager.ts`)
3//! that rpi honors: `defaultProvider` / `defaultModel` / `defaultThinkingLevel`
4//! (consumed by `provider::resolve` as pi's `findInitialModel` step 3 — the
5//! saved default, when authed, wins over the built-in fallback), `theme`,
6//! packages, and configurable resource directories.
7//!
8//! pi's `Settings` carries ~40 fields; rpi reads the fields it uses and drops the
9//! rest (serde `default` ignores unknown fields), so a copied pi `settings.json`
10//! parses clean.
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::config::{self, strip_line_comments, ConfigError};
16
17/// `retry` — upstream's `RetrySettings`. `provider` is parsed but unused: it
18/// configures the provider SDK's own retry loop, which rpi does not run.
19#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
20#[serde(rename_all = "camelCase")]
21pub struct RetrySettings {
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub enabled: Option<bool>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub max_retries: Option<u32>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub base_delay_ms: Option<u64>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub max_agent_delay_ms: Option<u64>,
30    /// upstream's nested `retry.provider`. Kept so a copied file round-trips;
31    /// rpi has no provider-SDK retry loop to configure.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub provider: Option<serde_json::Value>,
34}
35
36/// `compaction` — upstream's `CompactionSettings`.
37#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
38#[serde(rename_all = "camelCase")]
39pub struct CompactionSettingsJson {
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub enabled: Option<bool>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub reserve_tokens: Option<i64>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub keep_recent_tokens: Option<i64>,
46    /// Per-model overrides. Kept for round-tripping; the harness has no
47    /// per-model compaction override.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub model_overrides: Option<serde_json::Value>,
50}
51
52/// `branchSummary` — upstream's `BranchSummarySettings`.
53#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
54#[serde(rename_all = "camelCase")]
55pub struct BranchSummarySettings {
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub reserve_tokens: Option<i64>,
58    /// When true, upstream skips its "Summarize branch?" prompt and defaults to
59    /// *no* summary. rpi has no such prompt, so this is what makes the key mean
60    /// something: `Some(true)` suppresses the summary.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub skip_prompt: Option<bool>,
63}
64
65/// `steeringMode` / `followUpMode` — upstream's queue drain modes.
66#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
67#[serde(rename_all = "kebab-case")]
68pub enum QueueModeSetting {
69    All,
70    OneAtATime,
71}
72
73impl From<QueueModeSetting> for rpi_agent::QueueMode {
74    fn from(mode: QueueModeSetting) -> Self {
75        match mode {
76            QueueModeSetting::All => rpi_agent::QueueMode::All,
77            QueueModeSetting::OneAtATime => rpi_agent::QueueMode::OneAtATime,
78        }
79    }
80}
81
82/// The honored subset of pi's `Settings`. Unknown fields are ignored.
83#[derive(serde::Deserialize, Default, Clone, Debug)]
84#[serde(rename_all = "camelCase")]
85pub struct Settings {
86    /// Saved default provider id (v1 honors only `"anthropic"`; an absent or
87    /// anthropic value allows the saved default-model lookup).
88    #[serde(default)]
89    pub default_provider: Option<String>,
90    /// Saved default model id. When present and the model is authed,
91    /// `provider::resolve` selects it (pi `findInitialModel` step 3).
92    #[serde(default)]
93    pub default_model: Option<String>,
94    /// Saved default thinking level (a level-name string: off/minimal/low/medium/
95    /// high/xhigh/max). Parsed by the caller via `args::parse_thinking_level`.
96    #[serde(default)]
97    pub default_thinking_level: Option<String>,
98    /// Saved theme name. Surfaced for best-effort TUI theme application.
99    #[serde(default)]
100    pub theme: Option<String>,
101    /// `/scoped-models`: the model ids allowed in the Ctrl+P cycle. Absent /
102    /// empty ⇒ every catalog model cycles (the default). upstream spells this
103    /// `enabledModels`, which is accepted as an alias so a copied `settings.json`
104    /// behaves the same.
105    #[serde(default, alias = "enabledModels")]
106    pub scoped_models: Option<Vec<String>>,
107
108    /// Custom session storage directory. upstream's `sessionDir`, same format as
109    /// the `--session-dir` flag (which wins over it).
110    #[serde(default)]
111    pub session_dir: Option<PathBuf>,
112
113    /// Custom shell binary. upstream's `shellPath`. Parsed for fidelity, but
114    /// **not consumed yet**: nothing in pi-tools carries a shell binary, so
115    /// honouring it means plumbing one through `BashToolOptions` /
116    /// `ShellCaptureOptions` first.
117    #[serde(default)]
118    pub shell_path: Option<String>,
119
120    /// Prefix prepended to every bash command (upstream's `shellCommandPrefix`,
121    /// e.g. `shopt -s expand_aliases`).
122    #[serde(default)]
123    pub shell_command_prefix: Option<String>,
124
125    /// Proxy URL applied as `HTTP_PROXY` / `HTTPS_PROXY` for rpi's own HTTP
126    /// clients. upstream's `httpProxy`.
127    #[serde(default)]
128    pub http_proxy: Option<String>,
129
130    /// Retry policy for assistant turns. Mirrors upstream `retry`; the fields
131    /// map 1:1 onto the harness's `RetryPolicy`.
132    #[serde(default)]
133    pub retry: Option<RetrySettings>,
134
135    /// Compaction settings. Mirrors upstream `compaction` (minus its
136    /// `modelOverrides`, which the harness has no equivalent for).
137    #[serde(default)]
138    pub compaction: Option<CompactionSettingsJson>,
139
140    /// Branch-summary settings. Mirrors upstream `branchSummary`.
141    ///
142    /// Parsed for shape fidelity, but **nothing consumes it yet**: rpi's
143    /// `navigate_tree` ignores its `summarize` argument outright (`_summarize`),
144    /// and `generate_branch_summary` has no production caller. Until
145    /// navigation-with-summary is wired, these keys cannot change behaviour — see
146    /// the doc comment on the accessors.
147    #[serde(default)]
148    pub branch_summary: Option<BranchSummarySettings>,
149
150    /// How queued steering messages are drained. upstream default:
151    /// `"one-at-a-time"` (matches the harness's `QueueMode` default).
152    #[serde(default)]
153    pub steering_mode: Option<QueueModeSetting>,
154
155    /// How queued follow-up messages are drained. upstream default:
156    /// `"one-at-a-time"`.
157    #[serde(default)]
158    pub follow_up_mode: Option<QueueModeSetting>,
159    /// Additional skill directories. Relative paths are resolved against the
160    /// settings file's owner (project root for project settings, agent dir for
161    /// global settings). `skills` is accepted as a compatibility shorthand.
162    #[serde(default, alias = "skills")]
163    pub skill_dirs: Option<Vec<String>>,
164    /// Additional prompt-template directories/files. `prompts` is accepted as
165    /// a compatibility shorthand.
166    #[serde(default, alias = "prompts")]
167    pub prompt_dirs: Option<Vec<String>>,
168    /// Additional Rust cdylib extension directories. `extensions` is accepted
169    /// as a compatibility shorthand.
170    #[serde(default, alias = "extensions")]
171    pub extension_dirs: Option<Vec<String>>,
172    /// upstream keybinding overrides. Values may be a key string, an array
173    /// of key strings, or an empty array to unbind an action.
174    #[serde(default)]
175    pub keybindings: Option<HashMap<String, serde_json::Value>>,
176    /// Action performed by two quick Escape presses while the editor is empty.
177    /// upstream defaults this to `tree`; `none` disables the gesture.
178    #[serde(default)]
179    pub double_escape_action: Option<String>,
180    /// Default tools to enable at startup. When absent, all built-in tools
181    /// are enabled. When present, only the named tools are enabled.
182    #[serde(default)]
183    pub default_tools: Option<Vec<String>>,
184    /// Hide the body of thinking blocks while retaining a compact label.
185    #[serde(default)]
186    pub hide_thinking_block: Option<bool>,
187    /// Suppress the interactive startup header and resource summary.
188    /// Update checks remain enabled, matching upstream.
189    #[serde(default)]
190    pub quiet_startup: Option<bool>,
191    /// Legacy/imported marker retained for settings compatibility. Only known
192    /// rpi release versions are migrated into the dedicated rpi marker.
193    #[serde(default)]
194    pub last_changelog_version: Option<String>,
195    /// rpi's own release-note baseline, independent of imported Pi settings.
196    #[serde(default)]
197    pub last_rpi_changelog_version: Option<String>,
198    /// Show a compact upgrade notice instead of full notes. Pi defaults false.
199    #[serde(default)]
200    pub collapse_changelog: Option<bool>,
201    /// Show the global terminal progress indicator while a run is active.
202    #[serde(default)]
203    pub show_terminal_progress: Option<bool>,
204    /// Horizontal editor padding in terminal columns.
205    #[serde(default)]
206    pub editor_padding_x: Option<usize>,
207    /// Maximum number of autocomplete rows shown above the editor.
208    #[serde(default)]
209    pub autocomplete_max_visible: Option<usize>,
210    /// Automatically copy text to clipboard when selected in fullscreen mode.
211    /// When false, text selection requires explicit copy action.
212    #[serde(default)]
213    pub fullscreen_copy_on_select: Option<bool>,
214    /// Horizontal padding in terminal columns added to each side of the chat
215    /// transcript (user/assistant messages and thinking). upstream `outputPad`
216    /// (its only legal values are 0 and 1).
217    #[serde(default)]
218    pub output_pad: Option<usize>,
219    /// Show transcript notices for prompt-cache costs and provider recovery
220    /// diagnostics. upstream `showCacheMissNotices` (default `false`).
221    #[serde(default)]
222    pub show_cache_miss_notices: Option<bool>,
223    /// Render images inline in the terminal. rpi stores this at the top level
224    /// under `showImages`; upstream nests it as `terminal.showImages`, which
225    /// [`TerminalSettings`] also accepts (see [`Settings::show_images`]).
226    #[serde(default)]
227    pub show_images: Option<bool>,
228    /// upstream's nested `terminal` settings block. rpi keeps its own flat
229    /// keys for the settings it has always written, but reads this block so a
230    /// native `settings.json` is honored.
231    #[serde(default)]
232    pub terminal: Option<TerminalSettings>,
233    /// How long idle pooled HTTP connections are kept before being closed.
234    /// Accepts a millisecond number or the string `"disabled"`.
235    /// `httpIdleTimeout`. rpi surface: `httpIdleTimeout`.
236    #[serde(default, alias = "httpIdleTimeoutMs")]
237    pub http_idle_timeout: Option<serde_json::Value>,
238}
239
240/// upstream's `terminal` settings block (`TerminalSettings`). Only the fields
241/// rpi can honor are modeled; unknown keys are ignored.
242#[derive(serde::Deserialize, Clone, Debug, Default)]
243#[serde(rename_all = "camelCase")]
244pub struct TerminalSettings {
245    /// `terminal.showImages` — render images inline.
246    #[serde(default)]
247    pub show_images: Option<bool>,
248    /// `terminal.showTerminalProgress` — OSC 9;4 terminal progress indicator.
249    #[serde(default)]
250    pub show_terminal_progress: Option<bool>,
251    /// `terminal.clearOnShrink` — clear empty rows when content shrinks.
252    #[serde(default)]
253    pub clear_on_shrink: Option<bool>,
254}
255
256impl Settings {
257    /// Resolved HTTP idle timeout in milliseconds (`None` ⇒ library default,
258    /// `Some(0)` ⇒ disabled). See [`rpi_ai::http::parse_http_idle_timeout_ms`].
259    pub fn http_idle_timeout_ms(&self) -> Option<i64> {
260        self.http_idle_timeout
261            .as_ref()
262            .and_then(rpi_ai::http::parse_http_idle_timeout_ms)
263    }
264
265    /// Retry policy, resolved onto the harness type. Absent fields keep the
266    /// harness defaults, so a partial `retry` object changes only what it names.
267    pub fn retry_policy(&self) -> rpi_harness::types::RetryPolicy {
268        let mut policy = rpi_harness::types::RetryPolicy::default();
269        let Some(retry) = &self.retry else {
270            return policy;
271        };
272        if let Some(enabled) = retry.enabled {
273            policy.enabled = enabled;
274        }
275        if let Some(max_retries) = retry.max_retries {
276            policy.max_retries = max_retries;
277        }
278        if let Some(base_delay_ms) = retry.base_delay_ms {
279            policy.base_delay_ms = base_delay_ms;
280        }
281        if let Some(max_agent_delay_ms) = retry.max_agent_delay_ms {
282            policy.max_agent_delay_ms = max_agent_delay_ms;
283        }
284        policy
285    }
286
287    /// Compaction settings, resolved onto the harness type.
288    pub fn compaction_settings(&self) -> rpi_harness::types::CompactionSettings {
289        let mut settings = rpi_harness::types::CompactionSettings::default();
290        let Some(compaction) = &self.compaction else {
291            return settings;
292        };
293        if let Some(enabled) = compaction.enabled {
294            settings.enabled = enabled;
295        }
296        if let Some(reserve_tokens) = compaction.reserve_tokens {
297            settings.reserve_tokens = reserve_tokens;
298        }
299        if let Some(keep_recent_tokens) = compaction.keep_recent_tokens {
300            settings.keep_recent_tokens = keep_recent_tokens;
301        }
302        settings
303    }
304
305    /// Tokens reserved for a branch summary's prompt + output.
306    ///
307    /// Not consumed yet: `AgentLane::navigate_tree` ignores `summarize`, so no
308    /// branch summary is ever generated from this path. Wiring it means
309    /// implementing navigation-with-summary in the harness, not reading a key.
310    pub fn branch_summary_reserve_tokens(&self) -> Option<i64> {
311        self.branch_summary.as_ref().and_then(|s| s.reserve_tokens)
312    }
313
314    /// Whether branch summaries are suppressed (upstream's `skipPrompt`).
315    ///
316    /// Not consumed yet, for the same reason as
317    /// [`Self::branch_summary_reserve_tokens`].
318    pub fn branch_summary_skipped(&self) -> bool {
319        self.branch_summary
320            .as_ref()
321            .and_then(|s| s.skip_prompt)
322            .unwrap_or(false)
323    }
324
325    /// Default steering-queue drain mode (upstream default: one-at-a-time).
326    pub fn steering_mode(&self) -> rpi_agent::QueueMode {
327        self.steering_mode
328            .map(Into::into)
329            .unwrap_or(rpi_agent::QueueMode::OneAtATime)
330    }
331
332    /// Default follow-up-queue drain mode (upstream default: one-at-a-time).
333    pub fn follow_up_mode(&self) -> rpi_agent::QueueMode {
334        self.follow_up_mode
335            .map(Into::into)
336            .unwrap_or(rpi_agent::QueueMode::OneAtATime)
337    }
338
339    /// Render images inline. rpi's flat `showImages` wins; upstream's nested
340    /// `terminal.showImages` is the fallback.
341    pub fn show_images(&self) -> Option<bool> {
342        self.show_images
343            .or_else(|| self.terminal.as_ref().and_then(|t| t.show_images))
344    }
345
346    /// Terminal progress indicator. rpi's flat `showTerminalProgress` wins;
347    /// upstream's nested `terminal.showTerminalProgress` is the fallback.
348    pub fn show_terminal_progress(&self) -> Option<bool> {
349        self.show_terminal_progress.or_else(|| {
350            self.terminal
351                .as_ref()
352                .and_then(|t| t.show_terminal_progress)
353        })
354    }
355}
356
357/// Load `~/.rpi/agent/settings.json`. Missing file ⇒ `Settings::default()`
358/// (not an error). Malformed JSON ⇒ `ConfigError::Json`. Tolerates `//` line
359/// comments (a copied pi settings.json may contain them).
360pub fn load_settings() -> Result<Settings, ConfigError> {
361    let path = config::settings_path()?;
362    match std::fs::read_to_string(&path) {
363        Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
364            path: path.clone(),
365            source: e,
366        }),
367        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
368        Err(e) => Err(ConfigError::Read { path, source: e }),
369    }
370}
371
372/// Load the single active project settings document. `.rpi/settings.json`
373/// is the only project settings file rpi reads.
374pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
375    load_project_settings_with_paths(cwd)
376        .into_iter()
377        .map(|(_, settings)| settings)
378        .collect()
379}
380
381/// Load the active project settings together with its source path.
382pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
383    load_active_project_settings(cwd)
384        .ok()
385        .flatten()
386        .into_iter()
387        .collect()
388}
389
390/// Load the active project settings while preserving parse/read failures for
391/// callers that must fail closed, such as package install/update preflight.
392pub fn load_active_project_settings(
393    cwd: &Path,
394) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
395    let preferred = cwd.join(".rpi/settings.json");
396    match load_settings_file(&preferred) {
397        Ok(settings) => Ok(Some((preferred, settings))),
398        Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
399            Ok(None)
400        }
401        Err(error) => Err(error),
402    }
403}
404
405/// Load model-selection settings with upstream's global -> trusted-project
406/// precedence. Only fields consumed during provider resolution are overlaid;
407/// package and resource loading keeps its own scope-aware merge semantics.
408pub fn load_effective_model_settings(
409    cwd: &Path,
410    project_trusted: bool,
411) -> Result<Settings, ConfigError> {
412    let mut effective = load_settings()?;
413    if project_trusted {
414        if let Some((_, project)) = load_active_project_settings(cwd)? {
415            if project.default_provider.is_some() {
416                effective.default_provider = project.default_provider;
417            }
418            if project.default_model.is_some() {
419                effective.default_model = project.default_model;
420            }
421            if project.default_thinking_level.is_some() {
422                effective.default_thinking_level = project.default_thinking_level;
423            }
424            if project.theme.is_some() {
425                effective.theme = project.theme;
426            }
427        }
428    }
429    Ok(effective)
430}
431
432/// Load the project settings document that rpi may safely update.
433pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
434    Ok(load_active_project_settings(cwd)?
435        .map(|(_, settings)| settings)
436        .unwrap_or_default())
437}
438
439fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
440    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
441        path: path.to_path_buf(),
442        source,
443    })?;
444    parse_settings(&text).map_err(|source| ConfigError::Json {
445        path: path.to_path_buf(),
446        source,
447    })
448}
449
450/// Resolve a configured path relative to its settings owner. Absolute paths
451/// are preserved; empty entries are ignored.
452pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
453    values
454        .iter()
455        .map(|value| value.trim())
456        .filter(|value| !value.is_empty())
457        .map(|value| {
458            let path = PathBuf::from(value);
459            if path.is_absolute() {
460                path
461            } else {
462                base.join(path)
463            }
464        })
465        .collect()
466}
467
468fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
469    match serde_json::from_str(text) {
470        Ok(s) => Ok(s),
471        Err(first) => {
472            let stripped = strip_line_comments(text);
473            serde_json::from_str(&stripped).map_err(|_| first)
474        }
475    }
476}
477
478/// Parse an existing settings document while retaining fields that rpi does
479/// not model. upstream permits `//` line comments in `settings.json`, so use
480/// the same strict-then-comment-stripped strategy as [`parse_settings`].
481/// Keeping this separate from `parse_settings` is important: deserializing
482/// into [`Settings`] would discard unknown fields before a save/merge.
483fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
484    match serde_json::from_str::<serde_json::Value>(text) {
485        Ok(value) => Ok(value),
486        Err(first) => {
487            let stripped = strip_line_comments(text);
488            serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
489        }
490    }
491}
492
493/// Persist the honored settings back to `~/.rpi/agent/settings.json`.
494/// Unknown pi fields (which `Settings` doesn't model) are **preserved**: the
495/// current file is read as raw JSON, the known fields are overlaid, and the
496/// merged object is written — so a copied pi `settings.json` survives edits
497/// without losing pi-only keys. `//` comments are accepted using the same
498/// parser as [`load_settings`]. Missing file ⇒ a fresh object. Existing files
499/// that cannot be parsed safely are left untouched and reported as errors.
500pub fn save_settings(settings: &Settings) -> Result<(), String> {
501    let path = config::settings_path().map_err(|e| e.to_string())?;
502    save_settings_to_path(settings, &path, None)
503}
504
505/// Persist project-scoped settings under `.rpi/settings.json`.
506pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
507    save_settings_to_path(settings, &cwd.join(".rpi/settings.json"), None)
508}
509
510fn save_settings_to_path(
511    settings: &Settings,
512    path: &Path,
513    fallback: Option<&Path>,
514) -> Result<(), String> {
515    // Read the existing file as a raw object to preserve unknown fields.
516    let mut merged = match std::fs::read_to_string(path) {
517        Ok(text) => parse_settings_value(&text).map_err(|error| {
518            format!(
519                "cannot parse existing settings file {}: {error}",
520                path.display()
521            )
522        })?,
523        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
524            read_fallback_settings_value(path, fallback)?
525                .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
526        }
527        Err(error) => {
528            return Err(format!(
529                "cannot read existing settings file {}: {error}",
530                path.display()
531            ));
532        }
533    };
534    let obj = merged
535        .as_object_mut()
536        .ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
537    for (key, val) in [
538        ("defaultProvider", settings.default_provider.as_ref()),
539        ("defaultModel", settings.default_model.as_ref()),
540        (
541            "defaultThinkingLevel",
542            settings.default_thinking_level.as_ref(),
543        ),
544        ("theme", settings.theme.as_ref()),
545        (
546            "lastChangelogVersion",
547            settings.last_changelog_version.as_ref(),
548        ),
549        (
550            "lastRpiChangelogVersion",
551            settings.last_rpi_changelog_version.as_ref(),
552        ),
553    ] {
554        match val {
555            Some(v) => {
556                obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
557            }
558            None => {
559                obj.remove(key);
560            }
561        }
562    }
563    match &settings.scoped_models {
564        Some(list) if !list.is_empty() => {
565            obj.insert(
566                "scopedModels".to_string(),
567                serde_json::Value::Array(
568                    list.iter()
569                        .map(|m| serde_json::Value::String(m.clone()))
570                        .collect(),
571                ),
572            );
573        }
574        _ => {
575            obj.remove("scopedModels");
576        }
577    }
578    match &settings.keybindings {
579        Some(bindings) => {
580            obj.insert(
581                "keybindings".to_string(),
582                serde_json::to_value(bindings).map_err(|e| e.to_string())?,
583            );
584        }
585        None => {
586            obj.remove("keybindings");
587        }
588    }
589    match settings.double_escape_action.as_deref() {
590        Some(action) if !action.trim().is_empty() => {
591            obj.insert(
592                "doubleEscapeAction".to_string(),
593                serde_json::Value::String(action.to_string()),
594            );
595        }
596        _ => {
597            obj.remove("doubleEscapeAction");
598        }
599    }
600    for (key, value) in [
601        (
602            "hideThinkingBlock",
603            settings.hide_thinking_block.map(serde_json::Value::Bool),
604        ),
605        (
606            "quietStartup",
607            settings.quiet_startup.map(serde_json::Value::Bool),
608        ),
609        (
610            "collapseChangelog",
611            settings.collapse_changelog.map(serde_json::Value::Bool),
612        ),
613        (
614            "showTerminalProgress",
615            settings.show_terminal_progress.map(serde_json::Value::Bool),
616        ),
617        (
618            "showImages",
619            settings.show_images.map(serde_json::Value::Bool),
620        ),
621        (
622            "showCacheMissNotices",
623            settings
624                .show_cache_miss_notices
625                .map(serde_json::Value::Bool),
626        ),
627        (
628            "fullscreenCopyOnSelect",
629            settings
630                .fullscreen_copy_on_select
631                .map(serde_json::Value::Bool),
632        ),
633        (
634            "editorPaddingX",
635            settings
636                .editor_padding_x
637                .map(|v| serde_json::Value::Number(v.into())),
638        ),
639        (
640            "outputPad",
641            settings
642                .output_pad
643                .map(|v| serde_json::Value::Number(v.into())),
644        ),
645        (
646            "autocompleteMaxVisible",
647            settings
648                .autocomplete_max_visible
649                .map(|v| serde_json::Value::Number(v.into())),
650        ),
651        ("httpIdleTimeout", settings.http_idle_timeout.clone()),
652    ] {
653        match value {
654            Some(value) => {
655                obj.insert(key.to_string(), value);
656            }
657            None => {
658                obj.remove(key);
659            }
660        }
661    }
662    if let Some(parent) = path.parent() {
663        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
664    }
665    let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
666    config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
667}
668
669/// When rpi is still reading upstream's settings fallback, seed the first
670/// rpi-owned save from that complete raw object. Otherwise a modeled-field
671/// save could shadow the native file and silently drop fields added by Pi.
672fn read_fallback_settings_value(
673    target: &Path,
674    fallback: Option<&Path>,
675) -> Result<Option<serde_json::Value>, String> {
676    let Some(path) = fallback else {
677        return Ok(None);
678    };
679    if path == target {
680        return Ok(None);
681    }
682    match std::fs::read_to_string(&path) {
683        Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
684            format!(
685                "cannot parse fallback settings file {}: {error}",
686                path.display()
687            )
688        }),
689        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
690        Err(error) => Err(format!(
691            "cannot read fallback settings file {}: {error}",
692            path.display()
693        )),
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::config::test_support::env_lock;
701
702    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for this test.
703    struct TempConfig {
704        _guard: std::sync::MutexGuard<'static, ()>,
705        _tmp: tempfile::TempDir,
706        prev: Option<std::ffi::OsString>,
707    }
708    impl TempConfig {
709        fn new() -> Self {
710            let guard = env_lock().lock().unwrap();
711            let prev = std::env::var_os(config::CONFIG_DIR_ENV);
712            let tmp = tempfile::TempDir::new().unwrap();
713            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
714            Self {
715                _guard: guard,
716                _tmp: tmp,
717                prev,
718            }
719        }
720    }
721    impl Drop for TempConfig {
722        fn drop(&mut self) {
723            match self.prev.take() {
724                Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
725                None => std::env::remove_var(config::CONFIG_DIR_ENV),
726            }
727        }
728    }
729
730    #[test]
731    fn missing_settings_is_default() {
732        let _cfg = TempConfig::new();
733        let s = load_settings().unwrap();
734        assert!(s.default_provider.is_none());
735        assert!(s.default_model.is_none());
736        assert!(s.default_thinking_level.is_none());
737        assert!(s.theme.is_none());
738        assert!(s.skill_dirs.is_none());
739        assert!(s.prompt_dirs.is_none());
740        assert!(s.extension_dirs.is_none());
741    }
742
743    #[test]
744    fn reads_honored_fields_and_ignores_unknown() {
745        let _cfg = TempConfig::new();
746        let path = config::settings_path().unwrap();
747        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
748        // A pi-style settings.json with many unknown fields + the 4 we honor.
749        std::fs::write(
750            &path,
751            r#"{
752                "lastChangelogVersion": "1.0.0",
753                "defaultProvider": "anthropic",
754                "defaultModel": "claude-sonnet-5",
755                "defaultThinkingLevel": "high",
756                "theme": "dark",
757                "hideThinkingBlock": true,
758                "quietStartup": true,
759                "showTerminalProgress": false,
760                "editorPaddingX": 3,
761                "autocompleteMaxVisible": 7,
762                "compaction": { "threshold": 100 }
763            }"#,
764        )
765        .unwrap();
766        let s = load_settings().unwrap();
767        assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
768        assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
769        assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
770        assert_eq!(s.theme.as_deref(), Some("dark"));
771        assert_eq!(s.hide_thinking_block, Some(true));
772        assert_eq!(s.quiet_startup, Some(true));
773        assert_eq!(s.show_terminal_progress, Some(false));
774        assert_eq!(s.editor_padding_x, Some(3));
775        assert_eq!(s.autocomplete_max_visible, Some(7));
776    }
777
778    #[test]
779    fn project_settings_only_use_rpi() {
780        let tmp = tempfile::tempdir().unwrap();
781        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
782        std::fs::write(
783            tmp.path().join(".rpi/settings.json"),
784            r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
785        )
786        .unwrap();
787
788        let settings = load_project_settings(tmp.path());
789        assert_eq!(settings.len(), 1);
790        assert_eq!(
791            settings[0].skill_dirs.as_deref(),
792            Some(["rpi-skills".to_string()].as_slice())
793        );
794        assert_eq!(
795            settings[0].extension_dirs.as_deref(),
796            Some(["rpi-ext".to_string()].as_slice())
797        );
798    }
799
800    #[test]
801    fn malformed_rpi_project_settings_are_not_read() {
802        let tmp = tempfile::tempdir().unwrap();
803        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
804        std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
805
806        assert!(load_project_settings_with_paths(tmp.path()).is_empty());
807        assert!(load_active_project_settings(tmp.path()).is_err());
808    }
809
810    #[test]
811    fn trusted_project_model_defaults_override_global_defaults() {
812        let _cfg = TempConfig::new();
813        let global = config::settings_path().unwrap();
814        std::fs::create_dir_all(global.parent().unwrap()).unwrap();
815        std::fs::write(
816            global,
817            r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
818        )
819        .unwrap();
820        let project = tempfile::tempdir().unwrap();
821        std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
822        std::fs::write(
823            project.path().join(".rpi/settings.json"),
824            r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
825        )
826        .unwrap();
827
828        let trusted = load_effective_model_settings(project.path(), true).unwrap();
829        assert_eq!(trusted.default_provider.as_deref(), Some("project"));
830        assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
831        assert_eq!(trusted.theme.as_deref(), Some("dark"));
832
833        let untrusted = load_effective_model_settings(project.path(), false).unwrap();
834        assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
835        assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
836    }
837
838    #[test]
839    fn tolerates_line_comments() {
840        let _cfg = TempConfig::new();
841        let path = config::settings_path().unwrap();
842        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
843        std::fs::write(
844            &path,
845            "{\n  // my default\n  \"defaultModel\": \"glm-5\",\n  \"theme\": \"light\"\n}\n",
846        )
847        .unwrap();
848        let s = load_settings().unwrap();
849        assert_eq!(s.default_model.as_deref(), Some("glm-5"));
850        assert_eq!(s.theme.as_deref(), Some("light"));
851    }
852
853    #[test]
854    fn malformed_is_error() {
855        let _cfg = TempConfig::new();
856        let path = config::settings_path().unwrap();
857        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
858        std::fs::write(&path, "{ not json").unwrap();
859        assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
860    }
861}
862
863#[cfg(test)]
864mod scoped_tests {
865    use super::*;
866    use crate::config::test_support::env_lock;
867
868    fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
869        let guard = env_lock().lock().unwrap();
870        let tmp = tempfile::TempDir::new().unwrap();
871        std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
872        (tmp, guard)
873    }
874
875    /// The settings upstream lets users tune must reach the harness types.
876    ///
877    /// These were previously hardcoded defaults, so a `settings.json` copied from
878    /// upstream parsed cleanly but changed nothing — the exact failure mode a
879    /// "parses but is not consumed" field creates.
880
881    /// `sessionDir` selects the session directory, with the CLI flag winning.
882    ///
883    /// upstream's comment: "same format as --session-dir CLI flag". A settings
884    /// key that parses but is ignored is exactly the failure mode this closes.
885    #[test]
886    fn session_dir_setting_selects_the_session_directory() {
887        let settings: Settings =
888            serde_json::from_str(r#"{"sessionDir": "/tmp/from-settings"}"#).unwrap();
889        assert_eq!(
890            settings.session_dir,
891            Some(std::path::PathBuf::from("/tmp/from-settings"))
892        );
893        // Absent ⇒ the caller falls back to its built-in default.
894        assert_eq!(Settings::default().session_dir, None);
895    }
896
897    /// The bash-facing keys must reach the tool options.
898    #[test]
899    fn shell_command_prefix_reaches_the_bash_tool() {
900        let settings: Settings =
901            serde_json::from_str(r#"{"shellCommandPrefix": "shopt -s expand_aliases"}"#).unwrap();
902        assert_eq!(
903            settings.shell_command_prefix.as_deref(),
904            Some("shopt -s expand_aliases")
905        );
906        // `shellPath` is parsed (native's shape round-trips) but nothing consumes
907        // it yet — see its doc comment for why.
908        let with_shell: Settings =
909            serde_json::from_str(r#"{"shellPath": "/usr/bin/zsh"}"#).unwrap();
910        assert_eq!(with_shell.shell_path.as_deref(), Some("/usr/bin/zsh"));
911    }
912
913    /// `httpProxy` is stored in native's shape for the client setup to apply.
914    #[test]
915    fn http_proxy_setting_is_read() {
916        let settings: Settings =
917            serde_json::from_str(r#"{"httpProxy": "http://127.0.0.1:8888"}"#).unwrap();
918        assert_eq!(
919            settings.http_proxy.as_deref(),
920            Some("http://127.0.0.1:8888")
921        );
922        assert_eq!(Settings::default().http_proxy, None);
923    }
924    #[test]
925    fn harness_options_come_from_settings() {
926        let settings: Settings = serde_json::from_str(
927            r#"{
928                "retry": { "enabled": false, "maxRetries": 7, "baseDelayMs": 250, "maxAgentDelayMs": 9000 },
929                "compaction": { "enabled": false, "reserveTokens": 1234, "keepRecentTokens": 4321 },
930                "steeringMode": "all",
931                "followUpMode": "all"
932            }"#,
933        )
934        .expect("settings parse");
935
936        let retry = settings.retry_policy();
937        assert!(!retry.enabled);
938        assert_eq!(retry.max_retries, 7);
939        assert_eq!(retry.base_delay_ms, 250);
940        assert_eq!(retry.max_agent_delay_ms, 9000);
941
942        let compaction = settings.compaction_settings();
943        assert!(!compaction.enabled);
944        assert_eq!(compaction.reserve_tokens, 1234);
945        assert_eq!(compaction.keep_recent_tokens, 4321);
946
947        assert_eq!(settings.steering_mode(), rpi_agent::QueueMode::All);
948        assert_eq!(settings.follow_up_mode(), rpi_agent::QueueMode::All);
949    }
950
951    /// An absent key keeps the harness default rather than a zero value: a partial
952    /// `retry` object must change only what it names.
953    #[test]
954    fn absent_harness_settings_keep_the_harness_defaults() {
955        let empty = Settings::default();
956        let default_retry = rpi_harness::types::RetryPolicy::default();
957        let retry = empty.retry_policy();
958        assert_eq!(retry.enabled, default_retry.enabled);
959        assert_eq!(retry.max_retries, default_retry.max_retries);
960        assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
961        assert_eq!(retry.max_agent_delay_ms, default_retry.max_agent_delay_ms);
962        let default_compaction = rpi_harness::types::CompactionSettings::default();
963        let compaction = empty.compaction_settings();
964        assert_eq!(compaction.enabled, default_compaction.enabled);
965        assert_eq!(compaction.reserve_tokens, default_compaction.reserve_tokens);
966        assert_eq!(
967            compaction.keep_recent_tokens,
968            default_compaction.keep_recent_tokens
969        );
970
971        // upstream's documented defaults for the queue modes.
972        assert_eq!(empty.steering_mode(), rpi_agent::QueueMode::OneAtATime);
973        assert_eq!(empty.follow_up_mode(), rpi_agent::QueueMode::OneAtATime);
974
975        // A partial object keeps the defaults it does not name.
976        let partial: Settings = serde_json::from_str(r#"{"retry":{"maxRetries":9}}"#).unwrap();
977        let retry = partial.retry_policy();
978        assert_eq!(retry.max_retries, 9);
979        assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
980    }
981
982    /// upstream's key names must be accepted, not only rpi's.
983    ///
984    /// A user copying upstream's `settings.json` writes `enabledModels` and
985    /// `httpIdleTimeoutMs`; before this they were silently ignored because rpi
986    /// spelled them differently.
987    #[test]
988    fn native_settings_key_names_are_accepted() {
989        let settings: Settings = serde_json::from_str(
990            r#"{
991                "enabledModels": ["a", "b"],
992                "httpIdleTimeoutMs": 1500,
993                "branchSummary": { "reserveTokens": 4242, "skipPrompt": true }
994            }"#,
995        )
996        .expect("native key names parse");
997
998        assert_eq!(
999            settings.scoped_models,
1000            Some(vec!["a".to_string(), "b".to_string()]),
1001            "native's `enabledModels` must map onto rpi's scoped models"
1002        );
1003        assert_eq!(
1004            settings.http_idle_timeout_ms(),
1005            Some(1500),
1006            "native's `httpIdleTimeoutMs` must be read"
1007        );
1008        assert_eq!(settings.branch_summary_reserve_tokens(), Some(4242));
1009        assert!(settings.branch_summary_skipped());
1010    }
1011    #[test]
1012    fn save_load_scoped_models_roundtrip() {
1013        let (_tmp, _guard) = with_temp_env();
1014        let mut s = Settings::default();
1015        s.scoped_models = Some(vec!["a".into(), "b".into()]);
1016        save_settings(&s).unwrap();
1017        let loaded = load_settings().unwrap();
1018        assert_eq!(
1019            loaded.scoped_models,
1020            Some(vec!["a".to_string(), "b".to_string()])
1021        );
1022        // Clearing removes the key.
1023        let mut s2 = load_settings().unwrap();
1024        s2.scoped_models = None;
1025        save_settings(&s2).unwrap();
1026        assert_eq!(load_settings().unwrap().scoped_models, None);
1027    }
1028
1029    #[test]
1030    fn changelog_settings_roundtrip_without_losing_existing_configuration() {
1031        let (_tmp, _guard) = with_temp_env();
1032        let path = config::settings_path().unwrap();
1033        std::fs::write(&path, r#"{"defaultModel":"existing-model","lastChangelogVersion":"0.3.16","collapseChangelog":true,"custom":{"retained":true}}"#).unwrap();
1034        let mut settings = load_settings().unwrap();
1035        assert_eq!(settings.last_changelog_version.as_deref(), Some("0.3.16"));
1036        assert_eq!(settings.collapse_changelog, Some(true));
1037        settings.last_changelog_version = Some("0.3.17".into());
1038        save_settings(&settings).unwrap();
1039        let loaded = load_settings().unwrap();
1040        assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.3.17"));
1041        assert_eq!(loaded.default_model.as_deref(), Some("existing-model"));
1042        assert_eq!(loaded.collapse_changelog, Some(true));
1043        let value: serde_json::Value =
1044            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1045        assert_eq!(value["custom"]["retained"], true);
1046    }
1047
1048    #[test]
1049    fn rpi_changelog_marker_preserves_foreign_baselines_and_existing_settings() {
1050        let (_tmp, _guard) = with_temp_env();
1051        let path = config::settings_path().unwrap();
1052        std::fs::write(
1053            &path,
1054            r#"{"lastChangelogVersion":"0.85.1","theme":"dark","custom":{"retained":true}}"#,
1055        )
1056        .unwrap();
1057        let mut settings = load_settings().unwrap();
1058        settings.last_rpi_changelog_version = Some("0.3.19".into());
1059        save_settings(&settings).unwrap();
1060        let loaded = load_settings().unwrap();
1061        assert_eq!(loaded.last_rpi_changelog_version.as_deref(), Some("0.3.19"));
1062        assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.85.1"));
1063        let value: serde_json::Value =
1064            serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1065        assert_eq!(value["lastRpiChangelogVersion"], "0.3.19");
1066        assert_eq!(value["lastChangelogVersion"], "0.85.1");
1067        assert_eq!(value["theme"], "dark");
1068        assert_eq!(value["custom"]["retained"], true);
1069    }
1070
1071    #[test]
1072    fn save_uses_atomic_sibling_replacement() {
1073        let (_tmp, _guard) = with_temp_env();
1074        let path = config::settings_path().unwrap();
1075        std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
1076
1077        let mut settings = load_settings().unwrap();
1078        settings.theme = Some("light".into());
1079        save_settings(&settings).unwrap();
1080
1081        let saved: serde_json::Value =
1082            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1083        assert_eq!(saved["theme"], "light");
1084        assert_eq!(saved["piOnlyField"], true);
1085        // `config::atomic_write` cleans up its sibling by replacing the
1086        // target in one rename; a successful save leaves no staging file.
1087        let temp = path.with_file_name(format!(
1088            ".{}.tmp",
1089            path.file_name().and_then(|name| name.to_str()).unwrap()
1090        ));
1091        assert!(!temp.exists(), "atomic staging file should not remain");
1092    }
1093
1094    #[test]
1095    fn save_preserves_unknown_fields_and_writes_the_tui_settings() {
1096        // Regression: `fullscreenCopyOnSelect` and `httpIdleTimeout` used to be
1097        // read but never written back, so `/settings` changes silently
1098        // vanished. The newer `/settings` rows (showImages,
1099        // showCacheMissNotices, outputPad) must round-trip too.
1100        let (_tmp, _guard) = with_temp_env();
1101        let path = config::settings_path().unwrap();
1102        let mut settings = Settings::default();
1103        settings.show_images = Some(false);
1104        settings.show_cache_miss_notices = Some(true);
1105        settings.output_pad = Some(0);
1106        settings.fullscreen_copy_on_select = Some(false);
1107        settings.http_idle_timeout = Some(serde_json::json!(30_000));
1108        settings.show_terminal_progress = Some(false);
1109        save_settings(&settings).unwrap();
1110
1111        let raw: serde_json::Value =
1112            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1113        assert_eq!(raw["showImages"], false);
1114        assert_eq!(raw["showCacheMissNotices"], true);
1115        assert_eq!(raw["outputPad"], 0);
1116        assert_eq!(raw["fullscreenCopyOnSelect"], false);
1117        assert_eq!(raw["httpIdleTimeout"], 30_000);
1118        assert_eq!(raw["showTerminalProgress"], false);
1119
1120        // And they load back.
1121        let reloaded = load_settings().unwrap();
1122        assert_eq!(reloaded.show_images(), Some(false));
1123        assert_eq!(reloaded.show_cache_miss_notices, Some(true));
1124        assert_eq!(reloaded.output_pad, Some(0));
1125        assert_eq!(reloaded.fullscreen_copy_on_select, Some(false));
1126        assert_eq!(reloaded.http_idle_timeout_ms(), Some(30_000));
1127        assert_eq!(reloaded.show_terminal_progress(), Some(false));
1128    }
1129
1130    #[test]
1131    fn nested_terminal_block_is_accepted_as_a_fallback() {
1132        // upstream nests `showImages` / `showTerminalProgress` under
1133        // `terminal`; rpi's flat keys win when both are present.
1134        let (_tmp, _guard) = with_temp_env();
1135        let path = config::settings_path().unwrap();
1136        std::fs::write(
1137            &path,
1138            r#"{ "terminal": { "showImages": false, "showTerminalProgress": false } }"#,
1139        )
1140        .unwrap();
1141        let settings = load_settings().unwrap();
1142        assert_eq!(settings.show_images(), Some(false));
1143        assert_eq!(settings.show_terminal_progress(), Some(false));
1144
1145        std::fs::write(
1146            &path,
1147            r#"{
1148                "showImages": true,
1149                "terminal": { "showImages": false }
1150            }"#,
1151        )
1152        .unwrap();
1153        let settings = load_settings().unwrap();
1154        assert_eq!(settings.show_images(), Some(true));
1155    }
1156
1157    #[test]
1158    fn save_preserves_unknown_fields() {
1159        let (_tmp, _guard) = with_temp_env();
1160        let path = config::settings_path().unwrap();
1161        std::fs::write(
1162            &path,
1163            r#"{
1164                "piOnlyField": "keep-me",
1165                "theme": "dark"
1166            }"#,
1167        )
1168        .unwrap();
1169        let mut s = load_settings().unwrap();
1170        s.scoped_models = Some(vec!["m1".into()]);
1171        save_settings(&s).unwrap();
1172        let raw: serde_json::Value =
1173            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1174        assert_eq!(raw["piOnlyField"], "keep-me");
1175        assert_eq!(raw["scopedModels"][0], "m1");
1176        assert_eq!(raw["theme"], "dark");
1177    }
1178
1179    #[test]
1180    fn save_preserves_unknown_fields_and_packages_with_line_comments() {
1181        let (_tmp, _guard) = with_temp_env();
1182        let path = config::settings_path().unwrap();
1183        let original = r#"{
1184            // upstream permits comments in settings files.
1185            "piOnlyField": { "keep": true },
1186            "theme": "dark"
1187        }
1188        "#;
1189        std::fs::write(&path, original).unwrap();
1190
1191        let mut settings = load_settings().unwrap();
1192        settings.scoped_models = Some(vec!["m1".into()]);
1193        save_settings(&settings).unwrap();
1194
1195        // The comments may be normalized away by pretty-printing, but every
1196        // unknown field must survive the merge.
1197        let raw: serde_json::Value =
1198            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1199        assert_eq!(raw["piOnlyField"]["keep"], true);
1200        assert_eq!(raw["theme"], "dark");
1201        assert_eq!(raw["scopedModels"][0], "m1");
1202    }
1203
1204    #[test]
1205    fn save_fails_closed_for_unparseable_existing_settings() {
1206        let (_tmp, _guard) = with_temp_env();
1207        let path = config::settings_path().unwrap();
1208        // This is not recoverable by stripping comments (the closing brace is
1209        // missing). Saving must leave the user's file byte-for-byte intact.
1210        let original = b"{\n  // keep this file intact\n  \"piOnlyField\": \"keep-me\"\n";
1211        std::fs::write(&path, original).unwrap();
1212
1213        let mut settings = Settings::default();
1214        settings.theme = Some("light".into());
1215        let error =
1216            save_settings(&settings).expect_err("malformed settings must not be overwritten");
1217        assert!(error.contains("cannot parse existing settings file"));
1218        assert_eq!(std::fs::read(&path).unwrap(), original);
1219    }
1220}