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}
33
34/// Load `~/.rpi/agent/settings.json`. Missing file ⇒ `Settings::default()`
35/// (not an error). Malformed JSON ⇒ `ConfigError::Json`. Tolerates `//` line
36/// comments (a copied pi settings.json may contain them).
37pub fn load_settings() -> Result<Settings, ConfigError> {
38    let path = config::settings_path()?;
39    match std::fs::read_to_string(&path) {
40        Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
41            path: path.clone(),
42            source: e,
43        }),
44        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
45        Err(e) => Err(ConfigError::Read { path, source: e }),
46    }
47}
48
49fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
50    match serde_json::from_str(text) {
51        Ok(s) => Ok(s),
52        Err(first) => {
53            let stripped = strip_line_comments(text);
54            serde_json::from_str(&stripped).map_err(|_| first)
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::config::test_support::env_lock;
63
64    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for this test.
65    struct TempConfig {
66        _guard: std::sync::MutexGuard<'static, ()>,
67        _tmp: tempfile::TempDir,
68        prev: Option<std::ffi::OsString>,
69    }
70    impl TempConfig {
71        fn new() -> Self {
72            let guard = env_lock().lock().unwrap();
73            let prev = std::env::var_os(config::CONFIG_DIR_ENV);
74            let tmp = tempfile::TempDir::new().unwrap();
75            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
76            Self { _guard: guard, _tmp: tmp, prev }
77        }
78    }
79    impl Drop for TempConfig {
80        fn drop(&mut self) {
81            match self.prev.take() {
82                Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
83                None => std::env::remove_var(config::CONFIG_DIR_ENV),
84            }
85        }
86    }
87
88    #[test]
89    fn missing_settings_is_default() {
90        let _cfg = TempConfig::new();
91        let s = load_settings().unwrap();
92        assert!(s.default_provider.is_none());
93        assert!(s.default_model.is_none());
94        assert!(s.default_thinking_level.is_none());
95        assert!(s.theme.is_none());
96    }
97
98    #[test]
99    fn reads_honored_fields_and_ignores_unknown() {
100        let _cfg = TempConfig::new();
101        let path = config::settings_path().unwrap();
102        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
103        // A pi-style settings.json with many unknown fields + the 4 we honor.
104        std::fs::write(
105            &path,
106            r#"{
107                "lastChangelogVersion": "1.0.0",
108                "defaultProvider": "anthropic",
109                "defaultModel": "claude-sonnet-5",
110                "defaultThinkingLevel": "high",
111                "theme": "dark",
112                "hideThinkingBlock": true,
113                "compaction": { "threshold": 100 },
114                "packages": ["some-pkg"]
115            }"#,
116        )
117        .unwrap();
118        let s = load_settings().unwrap();
119        assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
120        assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
121        assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
122        assert_eq!(s.theme.as_deref(), Some("dark"));
123    }
124
125    #[test]
126    fn tolerates_line_comments() {
127        let _cfg = TempConfig::new();
128        let path = config::settings_path().unwrap();
129        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
130        std::fs::write(
131            &path,
132            "{\n  // my default\n  \"defaultModel\": \"glm-5\",\n  \"theme\": \"light\"\n}\n",
133        )
134        .unwrap();
135        let s = load_settings().unwrap();
136        assert_eq!(s.default_model.as_deref(), Some("glm-5"));
137        assert_eq!(s.theme.as_deref(), Some("light"));
138    }
139
140    #[test]
141    fn malformed_is_error() {
142        let _cfg = TempConfig::new();
143        let path = config::settings_path().unwrap();
144        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
145        std::fs::write(&path, "{ not json").unwrap();
146        assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
147    }
148}