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