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