Skip to main content

rpi_cli/
settings.rs

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