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