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) and `theme`.
6//!
7//! pi's `Settings` carries ~40 fields; rpi reads the 4 it uses and drops the
8//! rest (serde `default` ignores unknown fields), so a copied pi `settings.json`
9//! parses clean.
10
11use crate::config::{self, strip_line_comments, ConfigError};
12
13/// The honored subset of pi's `Settings`. Unknown fields are ignored.
14#[derive(serde::Deserialize, Default, Clone, Debug)]
15#[serde(rename_all = "camelCase")]
16pub struct Settings {
17    /// Saved default provider id (v1 honors only `"anthropic"`; an absent or
18    /// anthropic value allows the saved default-model lookup).
19    #[serde(default)]
20    pub default_provider: Option<String>,
21    /// Saved default model id. When present and the model is authed,
22    /// `provider::resolve` selects it (pi `findInitialModel` step 3).
23    #[serde(default)]
24    pub default_model: Option<String>,
25    /// Saved default thinking level (a level-name string: off/minimal/low/medium/
26    /// high/xhigh/max). Parsed by the caller via `args::parse_thinking_level`.
27    #[serde(default)]
28    pub default_thinking_level: Option<String>,
29    /// Saved theme name. Surfaced for best-effort TUI theme application.
30    #[serde(default)]
31    pub theme: Option<String>,
32    /// `/scoped-models`: the model ids allowed in the Ctrl+M cycle. Absent /
33    /// empty ⇒ every catalog model cycles (the default).
34    #[serde(default)]
35    pub scoped_models: Option<Vec<String>>,
36}
37
38/// Load `~/.rpi/agent/settings.json`. Missing file ⇒ `Settings::default()`
39/// (not an error). Malformed JSON ⇒ `ConfigError::Json`. Tolerates `//` line
40/// comments (a copied pi settings.json may contain them).
41pub fn load_settings() -> Result<Settings, ConfigError> {
42    let path = config::settings_path()?;
43    match std::fs::read_to_string(&path) {
44        Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
45            path: path.clone(),
46            source: e,
47        }),
48        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
49        Err(e) => Err(ConfigError::Read { path, source: e }),
50    }
51}
52
53fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
54    match serde_json::from_str(text) {
55        Ok(s) => Ok(s),
56        Err(first) => {
57            let stripped = strip_line_comments(text);
58            serde_json::from_str(&stripped).map_err(|_| first)
59        }
60    }
61}
62
63/// Persist the honored settings back to `~/.rpi/agent/settings.json`.
64/// Unknown pi fields (which `Settings` doesn't model) are **preserved**: the
65/// current file is read as raw JSON, the known fields are overlaid, and the
66/// merged object is written — so a copied pi `settings.json` survives edits
67/// without losing pi-only keys. Missing file ⇒ a fresh object. Best-effort
68/// errors are returned as strings for the caller to surface.
69pub fn save_settings(settings: &Settings) -> Result<(), String> {
70    let path = config::settings_path().map_err(|e| e.to_string())?;
71    // Read the existing file as a raw object to preserve unknown fields.
72    let mut merged = match std::fs::read_to_string(&path) {
73        Ok(text) => serde_json::from_str::<serde_json::Value>(&text)
74            .unwrap_or(serde_json::Value::Object(Default::default())),
75        Err(_) => serde_json::Value::Object(Default::default()),
76    };
77    let obj = merged
78        .as_object_mut()
79        .ok_or("settings file is not an object")?;
80    for (key, val) in [
81        ("defaultProvider", settings.default_provider.as_ref()),
82        ("defaultModel", settings.default_model.as_ref()),
83        (
84            "defaultThinkingLevel",
85            settings.default_thinking_level.as_ref(),
86        ),
87        ("theme", settings.theme.as_ref()),
88    ] {
89        match val {
90            Some(v) => {
91                obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
92            }
93            None => {
94                obj.remove(key);
95            }
96        }
97    }
98    match &settings.scoped_models {
99        Some(list) if !list.is_empty() => {
100            obj.insert(
101                "scopedModels".to_string(),
102                serde_json::Value::Array(
103                    list.iter()
104                        .map(|m| serde_json::Value::String(m.clone()))
105                        .collect(),
106                ),
107            );
108        }
109        _ => {
110            obj.remove("scopedModels");
111        }
112    }
113    if let Some(parent) = path.parent() {
114        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
115    }
116    let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
117    std::fs::write(&path, text).map_err(|e| e.to_string())
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::config::test_support::env_lock;
124
125    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for this test.
126    struct TempConfig {
127        _guard: std::sync::MutexGuard<'static, ()>,
128        _tmp: tempfile::TempDir,
129        prev: Option<std::ffi::OsString>,
130    }
131    impl TempConfig {
132        fn new() -> Self {
133            let guard = env_lock().lock().unwrap();
134            let prev = std::env::var_os(config::CONFIG_DIR_ENV);
135            let tmp = tempfile::TempDir::new().unwrap();
136            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
137            Self {
138                _guard: guard,
139                _tmp: tmp,
140                prev,
141            }
142        }
143    }
144    impl Drop for TempConfig {
145        fn drop(&mut self) {
146            match self.prev.take() {
147                Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
148                None => std::env::remove_var(config::CONFIG_DIR_ENV),
149            }
150        }
151    }
152
153    #[test]
154    fn missing_settings_is_default() {
155        let _cfg = TempConfig::new();
156        let s = load_settings().unwrap();
157        assert!(s.default_provider.is_none());
158        assert!(s.default_model.is_none());
159        assert!(s.default_thinking_level.is_none());
160        assert!(s.theme.is_none());
161    }
162
163    #[test]
164    fn reads_honored_fields_and_ignores_unknown() {
165        let _cfg = TempConfig::new();
166        let path = config::settings_path().unwrap();
167        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
168        // A pi-style settings.json with many unknown fields + the 4 we honor.
169        std::fs::write(
170            &path,
171            r#"{
172                "lastChangelogVersion": "1.0.0",
173                "defaultProvider": "anthropic",
174                "defaultModel": "claude-sonnet-5",
175                "defaultThinkingLevel": "high",
176                "theme": "dark",
177                "hideThinkingBlock": true,
178                "compaction": { "threshold": 100 },
179                "packages": ["some-pkg"]
180            }"#,
181        )
182        .unwrap();
183        let s = load_settings().unwrap();
184        assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
185        assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
186        assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
187        assert_eq!(s.theme.as_deref(), Some("dark"));
188    }
189
190    #[test]
191    fn tolerates_line_comments() {
192        let _cfg = TempConfig::new();
193        let path = config::settings_path().unwrap();
194        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
195        std::fs::write(
196            &path,
197            "{\n  // my default\n  \"defaultModel\": \"glm-5\",\n  \"theme\": \"light\"\n}\n",
198        )
199        .unwrap();
200        let s = load_settings().unwrap();
201        assert_eq!(s.default_model.as_deref(), Some("glm-5"));
202        assert_eq!(s.theme.as_deref(), Some("light"));
203    }
204
205    #[test]
206    fn malformed_is_error() {
207        let _cfg = TempConfig::new();
208        let path = config::settings_path().unwrap();
209        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
210        std::fs::write(&path, "{ not json").unwrap();
211        assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
212    }
213}
214
215#[cfg(test)]
216mod scoped_tests {
217    use super::*;
218    use crate::config::test_support::env_lock;
219
220    fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
221        let guard = env_lock().lock().unwrap();
222        let tmp = tempfile::TempDir::new().unwrap();
223        std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
224        (tmp, guard)
225    }
226
227    #[test]
228    fn save_load_scoped_models_roundtrip() {
229        let (_tmp, _guard) = with_temp_env();
230        let mut s = Settings::default();
231        s.scoped_models = Some(vec!["a".into(), "b".into()]);
232        save_settings(&s).unwrap();
233        let loaded = load_settings().unwrap();
234        assert_eq!(
235            loaded.scoped_models,
236            Some(vec!["a".to_string(), "b".to_string()])
237        );
238        // Clearing removes the key.
239        let mut s2 = load_settings().unwrap();
240        s2.scoped_models = None;
241        save_settings(&s2).unwrap();
242        assert_eq!(load_settings().unwrap().scoped_models, None);
243    }
244
245    #[test]
246    fn save_preserves_unknown_fields() {
247        let (_tmp, _guard) = with_temp_env();
248        let path = config::settings_path().unwrap();
249        std::fs::write(&path, r#"{"piOnlyField":"keep-me","theme":"dark"}"#).unwrap();
250        let mut s = load_settings().unwrap();
251        s.scoped_models = Some(vec!["m1".into()]);
252        save_settings(&s).unwrap();
253        let raw: serde_json::Value =
254            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
255        assert_eq!(raw["piOnlyField"], "keep-me");
256        assert_eq!(raw["scopedModels"][0], "m1");
257        assert_eq!(raw["theme"], "dark");
258    }
259}