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