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    /// Maximum silence between streamed response bytes, in seconds.
239    /// Absent uses 300 seconds; zero disables it; the CLI flag takes priority.
240    #[serde(default)]
241    pub stream_idle_timeout: Option<u64>,
242}
243
244/// upstream's `terminal` settings block (`TerminalSettings`). Only the fields
245/// rpi can honor are modeled; unknown keys are ignored.
246#[derive(serde::Deserialize, Clone, Debug, Default)]
247#[serde(rename_all = "camelCase")]
248pub struct TerminalSettings {
249    /// `terminal.showImages` — render images inline.
250    #[serde(default)]
251    pub show_images: Option<bool>,
252    /// `terminal.showTerminalProgress` — OSC 9;4 terminal progress indicator.
253    #[serde(default)]
254    pub show_terminal_progress: Option<bool>,
255    /// `terminal.clearOnShrink` — clear empty rows when content shrinks.
256    #[serde(default)]
257    pub clear_on_shrink: Option<bool>,
258}
259
260impl Settings {
261    /// Resolved HTTP idle timeout in milliseconds (`None` ⇒ library default,
262    /// `Some(0)` ⇒ disabled). See [`rpi_ai::http::parse_http_idle_timeout_ms`].
263    pub fn http_idle_timeout_ms(&self) -> Option<i64> {
264        self.http_idle_timeout
265            .as_ref()
266            .and_then(rpi_ai::http::parse_http_idle_timeout_ms)
267    }
268
269    /// Retry policy, resolved onto the harness type. Absent fields keep the
270    /// harness defaults, so a partial `retry` object changes only what it names.
271    pub fn retry_policy(&self) -> rpi_harness::types::RetryPolicy {
272        let mut policy = rpi_harness::types::RetryPolicy::default();
273        let Some(retry) = &self.retry else {
274            return policy;
275        };
276        if let Some(enabled) = retry.enabled {
277            policy.enabled = enabled;
278        }
279        if let Some(max_retries) = retry.max_retries {
280            policy.max_retries = max_retries;
281        }
282        if let Some(base_delay_ms) = retry.base_delay_ms {
283            policy.base_delay_ms = base_delay_ms;
284        }
285        if let Some(max_agent_delay_ms) = retry.max_agent_delay_ms {
286            policy.max_agent_delay_ms = max_agent_delay_ms;
287        }
288        policy
289    }
290
291    /// Compaction settings, resolved onto the harness type.
292    pub fn compaction_settings(&self) -> rpi_harness::types::CompactionSettings {
293        let mut settings = rpi_harness::types::CompactionSettings::default();
294        let Some(compaction) = &self.compaction else {
295            return settings;
296        };
297        if let Some(enabled) = compaction.enabled {
298            settings.enabled = enabled;
299        }
300        if let Some(reserve_tokens) = compaction.reserve_tokens {
301            settings.reserve_tokens = reserve_tokens;
302        }
303        if let Some(keep_recent_tokens) = compaction.keep_recent_tokens {
304            settings.keep_recent_tokens = keep_recent_tokens;
305        }
306        settings
307    }
308
309    /// Tokens reserved for a branch summary's prompt + output.
310    ///
311    /// Not consumed yet: `AgentLane::navigate_tree` ignores `summarize`, so no
312    /// branch summary is ever generated from this path. Wiring it means
313    /// implementing navigation-with-summary in the harness, not reading a key.
314    pub fn branch_summary_reserve_tokens(&self) -> Option<i64> {
315        self.branch_summary.as_ref().and_then(|s| s.reserve_tokens)
316    }
317
318    /// Whether branch summaries are suppressed (upstream's `skipPrompt`).
319    ///
320    /// Not consumed yet, for the same reason as
321    /// [`Self::branch_summary_reserve_tokens`].
322    pub fn branch_summary_skipped(&self) -> bool {
323        self.branch_summary
324            .as_ref()
325            .and_then(|s| s.skip_prompt)
326            .unwrap_or(false)
327    }
328
329    /// Default steering-queue drain mode (upstream default: one-at-a-time).
330    pub fn steering_mode(&self) -> rpi_agent::QueueMode {
331        self.steering_mode
332            .map(Into::into)
333            .unwrap_or(rpi_agent::QueueMode::OneAtATime)
334    }
335
336    /// Default follow-up-queue drain mode (upstream default: one-at-a-time).
337    pub fn follow_up_mode(&self) -> rpi_agent::QueueMode {
338        self.follow_up_mode
339            .map(Into::into)
340            .unwrap_or(rpi_agent::QueueMode::OneAtATime)
341    }
342
343    /// Render images inline. rpi's flat `showImages` wins; upstream's nested
344    /// `terminal.showImages` is the fallback.
345    pub fn show_images(&self) -> Option<bool> {
346        self.show_images
347            .or_else(|| self.terminal.as_ref().and_then(|t| t.show_images))
348    }
349
350    /// Terminal progress indicator. rpi's flat `showTerminalProgress` wins;
351    /// upstream's nested `terminal.showTerminalProgress` is the fallback.
352    pub fn show_terminal_progress(&self) -> Option<bool> {
353        self.show_terminal_progress.or_else(|| {
354            self.terminal
355                .as_ref()
356                .and_then(|t| t.show_terminal_progress)
357        })
358    }
359}
360
361/// Load `~/.rpi/agent/settings.json`. Missing file ⇒ `Settings::default()`
362/// (not an error). Malformed JSON ⇒ `ConfigError::Json`. Tolerates `//` line
363/// comments (a copied pi settings.json may contain them).
364pub fn load_settings() -> Result<Settings, ConfigError> {
365    let path = config::settings_path()?;
366    match std::fs::read_to_string(&path) {
367        Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
368            path: path.clone(),
369            source: e,
370        }),
371        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
372        Err(e) => Err(ConfigError::Read { path, source: e }),
373    }
374}
375
376/// Load the single active project settings document. `.rpi/settings.json`
377/// is the only project settings file rpi reads.
378pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
379    load_project_settings_with_paths(cwd)
380        .into_iter()
381        .map(|(_, settings)| settings)
382        .collect()
383}
384
385/// Load the active project settings together with its source path.
386pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
387    load_active_project_settings(cwd)
388        .ok()
389        .flatten()
390        .into_iter()
391        .collect()
392}
393
394/// Load the active project settings while preserving parse/read failures for
395/// callers that must fail closed, such as package install/update preflight.
396pub fn load_active_project_settings(
397    cwd: &Path,
398) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
399    let preferred = cwd.join(".rpi/settings.json");
400    match load_settings_file(&preferred) {
401        Ok(settings) => Ok(Some((preferred, settings))),
402        Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
403            Ok(None)
404        }
405        Err(error) => Err(error),
406    }
407}
408
409/// Load model-selection settings with upstream's global -> trusted-project
410/// precedence. Only fields consumed during provider resolution are overlaid;
411/// package and resource loading keeps its own scope-aware merge semantics.
412pub fn load_effective_model_settings(
413    cwd: &Path,
414    project_trusted: bool,
415) -> Result<Settings, ConfigError> {
416    let mut effective = load_settings()?;
417    if project_trusted {
418        if let Some((_, project)) = load_active_project_settings(cwd)? {
419            if project.default_provider.is_some() {
420                effective.default_provider = project.default_provider;
421            }
422            if project.default_model.is_some() {
423                effective.default_model = project.default_model;
424            }
425            if project.default_thinking_level.is_some() {
426                effective.default_thinking_level = project.default_thinking_level;
427            }
428            if project.theme.is_some() {
429                effective.theme = project.theme;
430            }
431        }
432    }
433    Ok(effective)
434}
435
436/// Load the project settings document that rpi may safely update.
437pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
438    Ok(load_active_project_settings(cwd)?
439        .map(|(_, settings)| settings)
440        .unwrap_or_default())
441}
442
443fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
444    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
445        path: path.to_path_buf(),
446        source,
447    })?;
448    parse_settings(&text).map_err(|source| ConfigError::Json {
449        path: path.to_path_buf(),
450        source,
451    })
452}
453
454/// Resolve a configured path relative to its settings owner. Absolute paths
455/// are preserved; empty entries are ignored.
456pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
457    values
458        .iter()
459        .map(|value| value.trim())
460        .filter(|value| !value.is_empty())
461        .map(|value| {
462            let path = PathBuf::from(value);
463            if path.is_absolute() {
464                path
465            } else {
466                base.join(path)
467            }
468        })
469        .collect()
470}
471
472fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
473    match serde_json::from_str(text) {
474        Ok(s) => Ok(s),
475        Err(first) => {
476            let stripped = strip_line_comments(text);
477            serde_json::from_str(&stripped).map_err(|_| first)
478        }
479    }
480}
481
482/// Parse an existing settings document while retaining fields that rpi does
483/// not model. upstream permits `//` line comments in `settings.json`, so use
484/// the same strict-then-comment-stripped strategy as [`parse_settings`].
485/// Keeping this separate from `parse_settings` is important: deserializing
486/// into [`Settings`] would discard unknown fields before a save/merge.
487fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
488    match serde_json::from_str::<serde_json::Value>(text) {
489        Ok(value) => Ok(value),
490        Err(first) => {
491            let stripped = strip_line_comments(text);
492            serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
493        }
494    }
495}
496
497/// Persist the honored settings back to `~/.rpi/agent/settings.json`.
498/// Unknown pi fields (which `Settings` doesn't model) are **preserved**: the
499/// current file is read as raw JSON, the known fields are overlaid, and the
500/// merged object is written — so a copied pi `settings.json` survives edits
501/// without losing pi-only keys. `//` comments are accepted using the same
502/// parser as [`load_settings`]. Missing file ⇒ a fresh object. Existing files
503/// that cannot be parsed safely are left untouched and reported as errors.
504pub fn save_settings(settings: &Settings) -> Result<(), String> {
505    let path = config::settings_path().map_err(|e| e.to_string())?;
506    save_settings_to_path(settings, &path, None)
507}
508
509/// Persist project-scoped settings under `.rpi/settings.json`.
510pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
511    save_settings_to_path(settings, &cwd.join(".rpi/settings.json"), None)
512}
513
514fn save_settings_to_path(
515    settings: &Settings,
516    path: &Path,
517    fallback: Option<&Path>,
518) -> Result<(), String> {
519    // Read the existing file as a raw object to preserve unknown fields.
520    let mut merged = match std::fs::read_to_string(path) {
521        Ok(text) => parse_settings_value(&text).map_err(|error| {
522            format!(
523                "cannot parse existing settings file {}: {error}",
524                path.display()
525            )
526        })?,
527        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
528            read_fallback_settings_value(path, fallback)?
529                .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
530        }
531        Err(error) => {
532            return Err(format!(
533                "cannot read existing settings file {}: {error}",
534                path.display()
535            ));
536        }
537    };
538    let obj = merged
539        .as_object_mut()
540        .ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
541    for (key, val) in [
542        ("defaultProvider", settings.default_provider.as_ref()),
543        ("defaultModel", settings.default_model.as_ref()),
544        (
545            "defaultThinkingLevel",
546            settings.default_thinking_level.as_ref(),
547        ),
548        ("theme", settings.theme.as_ref()),
549        (
550            "lastChangelogVersion",
551            settings.last_changelog_version.as_ref(),
552        ),
553        (
554            "lastRpiChangelogVersion",
555            settings.last_rpi_changelog_version.as_ref(),
556        ),
557    ] {
558        match val {
559            Some(v) => {
560                obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
561            }
562            None => {
563                obj.remove(key);
564            }
565        }
566    }
567    match &settings.scoped_models {
568        Some(list) if !list.is_empty() => {
569            obj.insert(
570                "scopedModels".to_string(),
571                serde_json::Value::Array(
572                    list.iter()
573                        .map(|m| serde_json::Value::String(m.clone()))
574                        .collect(),
575                ),
576            );
577        }
578        _ => {
579            obj.remove("scopedModels");
580        }
581    }
582    match &settings.keybindings {
583        Some(bindings) => {
584            obj.insert(
585                "keybindings".to_string(),
586                serde_json::to_value(bindings).map_err(|e| e.to_string())?,
587            );
588        }
589        None => {
590            obj.remove("keybindings");
591        }
592    }
593    match settings.double_escape_action.as_deref() {
594        Some(action) if !action.trim().is_empty() => {
595            obj.insert(
596                "doubleEscapeAction".to_string(),
597                serde_json::Value::String(action.to_string()),
598            );
599        }
600        _ => {
601            obj.remove("doubleEscapeAction");
602        }
603    }
604    for (key, value) in [
605        (
606            "hideThinkingBlock",
607            settings.hide_thinking_block.map(serde_json::Value::Bool),
608        ),
609        (
610            "quietStartup",
611            settings.quiet_startup.map(serde_json::Value::Bool),
612        ),
613        (
614            "collapseChangelog",
615            settings.collapse_changelog.map(serde_json::Value::Bool),
616        ),
617        (
618            "showTerminalProgress",
619            settings.show_terminal_progress.map(serde_json::Value::Bool),
620        ),
621        (
622            "showImages",
623            settings.show_images.map(serde_json::Value::Bool),
624        ),
625        (
626            "showCacheMissNotices",
627            settings
628                .show_cache_miss_notices
629                .map(serde_json::Value::Bool),
630        ),
631        (
632            "fullscreenCopyOnSelect",
633            settings
634                .fullscreen_copy_on_select
635                .map(serde_json::Value::Bool),
636        ),
637        (
638            "editorPaddingX",
639            settings
640                .editor_padding_x
641                .map(|v| serde_json::Value::Number(v.into())),
642        ),
643        (
644            "outputPad",
645            settings
646                .output_pad
647                .map(|v| serde_json::Value::Number(v.into())),
648        ),
649        (
650            "autocompleteMaxVisible",
651            settings
652                .autocomplete_max_visible
653                .map(|v| serde_json::Value::Number(v.into())),
654        ),
655        ("httpIdleTimeout", settings.http_idle_timeout.clone()),
656        (
657            "streamIdleTimeout",
658            settings.stream_idle_timeout.map(serde_json::Value::from),
659        ),
660    ] {
661        match value {
662            Some(value) => {
663                obj.insert(key.to_string(), value);
664            }
665            None => {
666                obj.remove(key);
667            }
668        }
669    }
670    if let Some(parent) = path.parent() {
671        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
672    }
673    let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
674    config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
675}
676
677/// When rpi is still reading upstream's settings fallback, seed the first
678/// rpi-owned save from that complete raw object. Otherwise a modeled-field
679/// save could shadow the native file and silently drop fields added by Pi.
680fn read_fallback_settings_value(
681    target: &Path,
682    fallback: Option<&Path>,
683) -> Result<Option<serde_json::Value>, String> {
684    let Some(path) = fallback else {
685        return Ok(None);
686    };
687    if path == target {
688        return Ok(None);
689    }
690    match std::fs::read_to_string(path) {
691        Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
692            format!(
693                "cannot parse fallback settings file {}: {error}",
694                path.display()
695            )
696        }),
697        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
698        Err(error) => Err(format!(
699            "cannot read fallback settings file {}: {error}",
700            path.display()
701        )),
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use crate::config::test_support::env_lock;
709
710    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for this test.
711    struct TempConfig {
712        _guard: std::sync::MutexGuard<'static, ()>,
713        _tmp: tempfile::TempDir,
714        prev: Option<std::ffi::OsString>,
715    }
716    impl TempConfig {
717        fn new() -> Self {
718            let guard = env_lock().lock().unwrap();
719            let prev = std::env::var_os(config::CONFIG_DIR_ENV);
720            let tmp = tempfile::TempDir::new().unwrap();
721            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
722            Self {
723                _guard: guard,
724                _tmp: tmp,
725                prev,
726            }
727        }
728    }
729    impl Drop for TempConfig {
730        fn drop(&mut self) {
731            match self.prev.take() {
732                Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
733                None => std::env::remove_var(config::CONFIG_DIR_ENV),
734            }
735        }
736    }
737
738    #[test]
739    fn missing_settings_is_default() {
740        let _cfg = TempConfig::new();
741        let s = load_settings().unwrap();
742        assert!(s.default_provider.is_none());
743        assert!(s.default_model.is_none());
744        assert!(s.default_thinking_level.is_none());
745        assert!(s.theme.is_none());
746        assert!(s.skill_dirs.is_none());
747        assert!(s.prompt_dirs.is_none());
748        assert!(s.extension_dirs.is_none());
749    }
750
751    #[test]
752    fn reads_honored_fields_and_ignores_unknown() {
753        let _cfg = TempConfig::new();
754        let path = config::settings_path().unwrap();
755        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
756        // A pi-style settings.json with many unknown fields + the 4 we honor.
757        std::fs::write(
758            &path,
759            r#"{
760                "lastChangelogVersion": "1.0.0",
761                "defaultProvider": "anthropic",
762                "defaultModel": "claude-sonnet-5",
763                "defaultThinkingLevel": "high",
764                "theme": "dark",
765                "hideThinkingBlock": true,
766                "quietStartup": true,
767                "showTerminalProgress": false,
768                "editorPaddingX": 3,
769                "autocompleteMaxVisible": 7,
770                "compaction": { "threshold": 100 }
771            }"#,
772        )
773        .unwrap();
774        let s = load_settings().unwrap();
775        assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
776        assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
777        assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
778        assert_eq!(s.theme.as_deref(), Some("dark"));
779        assert_eq!(s.hide_thinking_block, Some(true));
780        assert_eq!(s.quiet_startup, Some(true));
781        assert_eq!(s.show_terminal_progress, Some(false));
782        assert_eq!(s.editor_padding_x, Some(3));
783        assert_eq!(s.autocomplete_max_visible, Some(7));
784    }
785
786    #[test]
787    fn project_settings_only_use_rpi() {
788        let tmp = tempfile::tempdir().unwrap();
789        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
790        std::fs::write(
791            tmp.path().join(".rpi/settings.json"),
792            r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
793        )
794        .unwrap();
795
796        let settings = load_project_settings(tmp.path());
797        assert_eq!(settings.len(), 1);
798        assert_eq!(
799            settings[0].skill_dirs.as_deref(),
800            Some(["rpi-skills".to_string()].as_slice())
801        );
802        assert_eq!(
803            settings[0].extension_dirs.as_deref(),
804            Some(["rpi-ext".to_string()].as_slice())
805        );
806    }
807
808    #[test]
809    fn malformed_rpi_project_settings_are_not_read() {
810        let tmp = tempfile::tempdir().unwrap();
811        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
812        std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
813
814        assert!(load_project_settings_with_paths(tmp.path()).is_empty());
815        assert!(load_active_project_settings(tmp.path()).is_err());
816    }
817
818    #[test]
819    fn trusted_project_model_defaults_override_global_defaults() {
820        let _cfg = TempConfig::new();
821        let global = config::settings_path().unwrap();
822        std::fs::create_dir_all(global.parent().unwrap()).unwrap();
823        std::fs::write(
824            global,
825            r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
826        )
827        .unwrap();
828        let project = tempfile::tempdir().unwrap();
829        std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
830        std::fs::write(
831            project.path().join(".rpi/settings.json"),
832            r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
833        )
834        .unwrap();
835
836        let trusted = load_effective_model_settings(project.path(), true).unwrap();
837        assert_eq!(trusted.default_provider.as_deref(), Some("project"));
838        assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
839        assert_eq!(trusted.theme.as_deref(), Some("dark"));
840
841        let untrusted = load_effective_model_settings(project.path(), false).unwrap();
842        assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
843        assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
844    }
845
846    #[test]
847    fn tolerates_line_comments() {
848        let _cfg = TempConfig::new();
849        let path = config::settings_path().unwrap();
850        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
851        std::fs::write(
852            &path,
853            "{\n  // my default\n  \"defaultModel\": \"glm-5\",\n  \"theme\": \"light\"\n}\n",
854        )
855        .unwrap();
856        let s = load_settings().unwrap();
857        assert_eq!(s.default_model.as_deref(), Some("glm-5"));
858        assert_eq!(s.theme.as_deref(), Some("light"));
859    }
860
861    #[test]
862    fn malformed_is_error() {
863        let _cfg = TempConfig::new();
864        let path = config::settings_path().unwrap();
865        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
866        std::fs::write(&path, "{ not json").unwrap();
867        assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
868    }
869}
870
871#[cfg(test)]
872mod scoped_tests {
873    use super::*;
874    use crate::config::test_support::env_lock;
875
876    fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
877        let guard = env_lock().lock().unwrap();
878        let tmp = tempfile::TempDir::new().unwrap();
879        std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
880        (tmp, guard)
881    }
882
883    /// The settings upstream lets users tune must reach the harness types.
884    ///
885    /// These were previously hardcoded defaults, so a `settings.json` copied from
886    /// upstream parsed cleanly but changed nothing — the exact failure mode a
887    /// "parses but is not consumed" field creates.
888
889    /// `sessionDir` selects the session directory, with the CLI flag winning.
890    ///
891    /// upstream's comment: "same format as --session-dir CLI flag". A settings
892    /// key that parses but is ignored is exactly the failure mode this closes.
893    #[test]
894    fn session_dir_setting_selects_the_session_directory() {
895        let settings: Settings =
896            serde_json::from_str(r#"{"sessionDir": "/tmp/from-settings"}"#).unwrap();
897        assert_eq!(
898            settings.session_dir,
899            Some(std::path::PathBuf::from("/tmp/from-settings"))
900        );
901        // Absent ⇒ the caller falls back to its built-in default.
902        assert_eq!(Settings::default().session_dir, None);
903    }
904
905    /// The bash-facing keys must reach the tool options.
906    #[test]
907    fn shell_command_prefix_reaches_the_bash_tool() {
908        let settings: Settings =
909            serde_json::from_str(r#"{"shellCommandPrefix": "shopt -s expand_aliases"}"#).unwrap();
910        assert_eq!(
911            settings.shell_command_prefix.as_deref(),
912            Some("shopt -s expand_aliases")
913        );
914        // `shellPath` is parsed (native's shape round-trips) but nothing consumes
915        // it yet — see its doc comment for why.
916        let with_shell: Settings =
917            serde_json::from_str(r#"{"shellPath": "/usr/bin/zsh"}"#).unwrap();
918        assert_eq!(with_shell.shell_path.as_deref(), Some("/usr/bin/zsh"));
919    }
920
921    /// `httpProxy` is stored in native's shape for the client setup to apply.
922    #[test]
923    fn http_proxy_setting_is_read() {
924        let settings: Settings =
925            serde_json::from_str(r#"{"httpProxy": "http://127.0.0.1:8888"}"#).unwrap();
926        assert_eq!(
927            settings.http_proxy.as_deref(),
928            Some("http://127.0.0.1:8888")
929        );
930        assert_eq!(Settings::default().http_proxy, None);
931    }
932    #[test]
933    fn harness_options_come_from_settings() {
934        let settings: Settings = serde_json::from_str(
935            r#"{
936                "retry": { "enabled": false, "maxRetries": 7, "baseDelayMs": 250, "maxAgentDelayMs": 9000 },
937                "compaction": { "enabled": false, "reserveTokens": 1234, "keepRecentTokens": 4321 },
938                "steeringMode": "all",
939                "followUpMode": "all"
940            }"#,
941        )
942        .expect("settings parse");
943
944        let retry = settings.retry_policy();
945        assert!(!retry.enabled);
946        assert_eq!(retry.max_retries, 7);
947        assert_eq!(retry.base_delay_ms, 250);
948        assert_eq!(retry.max_agent_delay_ms, 9000);
949
950        let compaction = settings.compaction_settings();
951        assert!(!compaction.enabled);
952        assert_eq!(compaction.reserve_tokens, 1234);
953        assert_eq!(compaction.keep_recent_tokens, 4321);
954
955        assert_eq!(settings.steering_mode(), rpi_agent::QueueMode::All);
956        assert_eq!(settings.follow_up_mode(), rpi_agent::QueueMode::All);
957    }
958
959    /// An absent key keeps the harness default rather than a zero value: a partial
960    /// `retry` object must change only what it names.
961    #[test]
962    fn absent_harness_settings_keep_the_harness_defaults() {
963        let empty = Settings::default();
964        let default_retry = rpi_harness::types::RetryPolicy::default();
965        let retry = empty.retry_policy();
966        assert_eq!(retry.enabled, default_retry.enabled);
967        assert_eq!(retry.max_retries, default_retry.max_retries);
968        assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
969        assert_eq!(retry.max_agent_delay_ms, default_retry.max_agent_delay_ms);
970        let default_compaction = rpi_harness::types::CompactionSettings::default();
971        let compaction = empty.compaction_settings();
972        assert_eq!(compaction.enabled, default_compaction.enabled);
973        assert_eq!(compaction.reserve_tokens, default_compaction.reserve_tokens);
974        assert_eq!(
975            compaction.keep_recent_tokens,
976            default_compaction.keep_recent_tokens
977        );
978
979        // upstream's documented defaults for the queue modes.
980        assert_eq!(empty.steering_mode(), rpi_agent::QueueMode::OneAtATime);
981        assert_eq!(empty.follow_up_mode(), rpi_agent::QueueMode::OneAtATime);
982
983        // A partial object keeps the defaults it does not name.
984        let partial: Settings = serde_json::from_str(r#"{"retry":{"maxRetries":9}}"#).unwrap();
985        let retry = partial.retry_policy();
986        assert_eq!(retry.max_retries, 9);
987        assert_eq!(retry.base_delay_ms, default_retry.base_delay_ms);
988    }
989
990    /// upstream's key names must be accepted, not only rpi's.
991    ///
992    /// A user copying upstream's `settings.json` writes `enabledModels` and
993    /// `httpIdleTimeoutMs`; before this they were silently ignored because rpi
994    /// spelled them differently.
995    #[test]
996    fn native_settings_key_names_are_accepted() {
997        let settings: Settings = serde_json::from_str(
998            r#"{
999                "enabledModels": ["a", "b"],
1000                "httpIdleTimeoutMs": 1500,
1001                "branchSummary": { "reserveTokens": 4242, "skipPrompt": true }
1002            }"#,
1003        )
1004        .expect("native key names parse");
1005
1006        assert_eq!(
1007            settings.scoped_models,
1008            Some(vec!["a".to_string(), "b".to_string()]),
1009            "native's `enabledModels` must map onto rpi's scoped models"
1010        );
1011        assert_eq!(
1012            settings.http_idle_timeout_ms(),
1013            Some(1500),
1014            "native's `httpIdleTimeoutMs` must be read"
1015        );
1016        assert_eq!(settings.branch_summary_reserve_tokens(), Some(4242));
1017        assert!(settings.branch_summary_skipped());
1018    }
1019    #[test]
1020    fn save_load_scoped_models_roundtrip() {
1021        let (_tmp, _guard) = with_temp_env();
1022        let mut s = Settings::default();
1023        s.scoped_models = Some(vec!["a".into(), "b".into()]);
1024        save_settings(&s).unwrap();
1025        let loaded = load_settings().unwrap();
1026        assert_eq!(
1027            loaded.scoped_models,
1028            Some(vec!["a".to_string(), "b".to_string()])
1029        );
1030        // Clearing removes the key.
1031        let mut s2 = load_settings().unwrap();
1032        s2.scoped_models = None;
1033        save_settings(&s2).unwrap();
1034        assert_eq!(load_settings().unwrap().scoped_models, None);
1035    }
1036
1037    #[test]
1038    fn changelog_settings_roundtrip_without_losing_existing_configuration() {
1039        let (_tmp, _guard) = with_temp_env();
1040        let path = config::settings_path().unwrap();
1041        std::fs::write(&path, r#"{"defaultModel":"existing-model","lastChangelogVersion":"0.3.16","collapseChangelog":true,"custom":{"retained":true}}"#).unwrap();
1042        let mut settings = load_settings().unwrap();
1043        assert_eq!(settings.last_changelog_version.as_deref(), Some("0.3.16"));
1044        assert_eq!(settings.collapse_changelog, Some(true));
1045        settings.last_changelog_version = Some("0.3.17".into());
1046        save_settings(&settings).unwrap();
1047        let loaded = load_settings().unwrap();
1048        assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.3.17"));
1049        assert_eq!(loaded.default_model.as_deref(), Some("existing-model"));
1050        assert_eq!(loaded.collapse_changelog, Some(true));
1051        let value: serde_json::Value =
1052            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1053        assert_eq!(value["custom"]["retained"], true);
1054    }
1055
1056    #[test]
1057    fn rpi_changelog_marker_preserves_foreign_baselines_and_existing_settings() {
1058        let (_tmp, _guard) = with_temp_env();
1059        let path = config::settings_path().unwrap();
1060        std::fs::write(
1061            &path,
1062            r#"{"lastChangelogVersion":"0.85.1","theme":"dark","custom":{"retained":true}}"#,
1063        )
1064        .unwrap();
1065        let mut settings = load_settings().unwrap();
1066        settings.last_rpi_changelog_version = Some("0.3.19".into());
1067        save_settings(&settings).unwrap();
1068        let loaded = load_settings().unwrap();
1069        assert_eq!(loaded.last_rpi_changelog_version.as_deref(), Some("0.3.19"));
1070        assert_eq!(loaded.last_changelog_version.as_deref(), Some("0.85.1"));
1071        let value: serde_json::Value =
1072            serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
1073        assert_eq!(value["lastRpiChangelogVersion"], "0.3.19");
1074        assert_eq!(value["lastChangelogVersion"], "0.85.1");
1075        assert_eq!(value["theme"], "dark");
1076        assert_eq!(value["custom"]["retained"], true);
1077    }
1078
1079    #[test]
1080    fn save_uses_atomic_sibling_replacement() {
1081        let (_tmp, _guard) = with_temp_env();
1082        let path = config::settings_path().unwrap();
1083        std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
1084
1085        let mut settings = load_settings().unwrap();
1086        settings.theme = Some("light".into());
1087        save_settings(&settings).unwrap();
1088
1089        let saved: serde_json::Value =
1090            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1091        assert_eq!(saved["theme"], "light");
1092        assert_eq!(saved["piOnlyField"], true);
1093        // `config::atomic_write` cleans up its sibling by replacing the
1094        // target in one rename; a successful save leaves no staging file.
1095        let temp = path.with_file_name(format!(
1096            ".{}.tmp",
1097            path.file_name().and_then(|name| name.to_str()).unwrap()
1098        ));
1099        assert!(!temp.exists(), "atomic staging file should not remain");
1100    }
1101
1102    #[test]
1103    fn save_preserves_unknown_fields_and_writes_the_tui_settings() {
1104        // Regression: `fullscreenCopyOnSelect` and `httpIdleTimeout` used to be
1105        // read but never written back, so `/settings` changes silently
1106        // vanished. The newer `/settings` rows (showImages,
1107        // showCacheMissNotices, outputPad) must round-trip too.
1108        let (_tmp, _guard) = with_temp_env();
1109        let path = config::settings_path().unwrap();
1110        let mut settings = Settings::default();
1111        settings.show_images = Some(false);
1112        settings.show_cache_miss_notices = Some(true);
1113        settings.output_pad = Some(0);
1114        settings.fullscreen_copy_on_select = Some(false);
1115        settings.http_idle_timeout = Some(serde_json::json!(30_000));
1116        settings.show_terminal_progress = Some(false);
1117        save_settings(&settings).unwrap();
1118
1119        let raw: serde_json::Value =
1120            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1121        assert_eq!(raw["showImages"], false);
1122        assert_eq!(raw["showCacheMissNotices"], true);
1123        assert_eq!(raw["outputPad"], 0);
1124        assert_eq!(raw["fullscreenCopyOnSelect"], false);
1125        assert_eq!(raw["httpIdleTimeout"], 30_000);
1126        assert_eq!(raw["showTerminalProgress"], false);
1127
1128        // And they load back.
1129        let reloaded = load_settings().unwrap();
1130        assert_eq!(reloaded.show_images(), Some(false));
1131        assert_eq!(reloaded.show_cache_miss_notices, Some(true));
1132        assert_eq!(reloaded.output_pad, Some(0));
1133        assert_eq!(reloaded.fullscreen_copy_on_select, Some(false));
1134        assert_eq!(reloaded.http_idle_timeout_ms(), Some(30_000));
1135        assert_eq!(reloaded.show_terminal_progress(), Some(false));
1136    }
1137
1138    #[test]
1139    fn nested_terminal_block_is_accepted_as_a_fallback() {
1140        // upstream nests `showImages` / `showTerminalProgress` under
1141        // `terminal`; rpi's flat keys win when both are present.
1142        let (_tmp, _guard) = with_temp_env();
1143        let path = config::settings_path().unwrap();
1144        std::fs::write(
1145            &path,
1146            r#"{ "terminal": { "showImages": false, "showTerminalProgress": false } }"#,
1147        )
1148        .unwrap();
1149        let settings = load_settings().unwrap();
1150        assert_eq!(settings.show_images(), Some(false));
1151        assert_eq!(settings.show_terminal_progress(), Some(false));
1152
1153        std::fs::write(
1154            &path,
1155            r#"{
1156                "showImages": true,
1157                "terminal": { "showImages": false }
1158            }"#,
1159        )
1160        .unwrap();
1161        let settings = load_settings().unwrap();
1162        assert_eq!(settings.show_images(), Some(true));
1163    }
1164
1165    #[test]
1166    fn save_preserves_unknown_fields() {
1167        let (_tmp, _guard) = with_temp_env();
1168        let path = config::settings_path().unwrap();
1169        std::fs::write(
1170            &path,
1171            r#"{
1172                "piOnlyField": "keep-me",
1173                "theme": "dark"
1174            }"#,
1175        )
1176        .unwrap();
1177        let mut s = load_settings().unwrap();
1178        s.scoped_models = Some(vec!["m1".into()]);
1179        save_settings(&s).unwrap();
1180        let raw: serde_json::Value =
1181            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1182        assert_eq!(raw["piOnlyField"], "keep-me");
1183        assert_eq!(raw["scopedModels"][0], "m1");
1184        assert_eq!(raw["theme"], "dark");
1185    }
1186
1187    #[test]
1188    fn stream_idle_timeout_settings_round_trip_including_disabled() {
1189        let (_tmp, _guard) = with_temp_env();
1190        for seconds in [45, 0] {
1191            let mut settings = load_settings().unwrap();
1192            settings.stream_idle_timeout = Some(seconds);
1193            save_settings(&settings).unwrap();
1194            assert_eq!(load_settings().unwrap().stream_idle_timeout, Some(seconds));
1195        }
1196    }
1197
1198    #[test]
1199    fn save_preserves_unknown_fields_and_packages_with_line_comments() {
1200        let (_tmp, _guard) = with_temp_env();
1201        let path = config::settings_path().unwrap();
1202        let original = r#"{
1203            // upstream permits comments in settings files.
1204            "piOnlyField": { "keep": true },
1205            "theme": "dark"
1206        }
1207        "#;
1208        std::fs::write(&path, original).unwrap();
1209
1210        let mut settings = load_settings().unwrap();
1211        settings.scoped_models = Some(vec!["m1".into()]);
1212        save_settings(&settings).unwrap();
1213
1214        // The comments may be normalized away by pretty-printing, but every
1215        // unknown field must survive the merge.
1216        let raw: serde_json::Value =
1217            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1218        assert_eq!(raw["piOnlyField"]["keep"], true);
1219        assert_eq!(raw["theme"], "dark");
1220        assert_eq!(raw["scopedModels"][0], "m1");
1221    }
1222
1223    #[test]
1224    fn save_fails_closed_for_unparseable_existing_settings() {
1225        let (_tmp, _guard) = with_temp_env();
1226        let path = config::settings_path().unwrap();
1227        // This is not recoverable by stripping comments (the closing brace is
1228        // missing). Saving must leave the user's file byte-for-byte intact.
1229        let original = b"{\n  // keep this file intact\n  \"piOnlyField\": \"keep-me\"\n";
1230        std::fs::write(&path, original).unwrap();
1231
1232        let mut settings = Settings::default();
1233        settings.theme = Some("light".into());
1234        let error =
1235            save_settings(&settings).expect_err("malformed settings must not be overwritten");
1236        assert!(error.contains("cannot parse existing settings file"));
1237        assert_eq!(std::fs::read(&path).unwrap(), original);
1238    }
1239}