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/// A package entry from Pi's `packages` setting.
18///
19/// The string form loads every resource exposed by the package. The object
20/// form can restrict individual resource kinds through [`PackageFilter`].
21#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
22#[serde(untagged)]
23pub enum PackageSetting {
24    Source(String),
25    Filtered(PackageFilter),
26}
27
28impl PackageSetting {
29    /// Return the npm, git, or local source spec regardless of entry form.
30    pub fn source(&self) -> &str {
31        match self {
32            Self::Source(source) => source,
33            Self::Filtered(filter) => &filter.source,
34        }
35    }
36}
37
38impl From<String> for PackageSetting {
39    fn from(source: String) -> Self {
40        Self::Source(source)
41    }
42}
43
44impl From<&str> for PackageSetting {
45    fn from(source: &str) -> Self {
46        Self::Source(source.to_string())
47    }
48}
49
50/// Resource filters for Pi's object-form package setting.
51///
52/// Additional properties are retained so loading and saving settings with a
53/// newer Pi package schema never discards fields rpi does not yet understand.
54#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
55#[serde(rename_all = "camelCase")]
56pub struct PackageFilter {
57    pub source: String,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub autoload: Option<bool>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub extensions: Option<Vec<String>>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub skills: Option<Vec<String>>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub prompts: Option<Vec<String>>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub themes: Option<Vec<String>>,
68    #[serde(flatten)]
69    pub unknown: serde_json::Map<String, serde_json::Value>,
70}
71
72/// The honored subset of pi's `Settings`. Unknown fields are ignored.
73#[derive(serde::Deserialize, Default, Clone, Debug)]
74#[serde(rename_all = "camelCase")]
75pub struct Settings {
76    /// Saved default provider id (v1 honors only `"anthropic"`; an absent or
77    /// anthropic value allows the saved default-model lookup).
78    #[serde(default)]
79    pub default_provider: Option<String>,
80    /// Saved default model id. When present and the model is authed,
81    /// `provider::resolve` selects it (pi `findInitialModel` step 3).
82    #[serde(default)]
83    pub default_model: Option<String>,
84    /// Saved default thinking level (a level-name string: off/minimal/low/medium/
85    /// high/xhigh/max). Parsed by the caller via `args::parse_thinking_level`.
86    #[serde(default)]
87    pub default_thinking_level: Option<String>,
88    /// Saved theme name. Surfaced for best-effort TUI theme application.
89    #[serde(default)]
90    pub theme: Option<String>,
91    /// `/scoped-models`: the model ids allowed in the Ctrl+M cycle. Absent /
92    /// empty ⇒ every catalog model cycles (the default).
93    #[serde(default)]
94    pub scoped_models: Option<Vec<String>>,
95    /// Pi-compatible static package specs. Entries may be local package
96    /// directories, `package.json` files, installed package names, or filtered
97    /// package objects.
98    #[serde(default)]
99    pub packages: Option<Vec<PackageSetting>>,
100    /// Command used by native Pi for npm package lookup/install operations.
101    /// Stored argv-style so launchers such as `mise exec -- npm` need no shell.
102    #[serde(default)]
103    pub npm_command: Option<Vec<String>>,
104    /// Additional skill directories. Relative paths are resolved against the
105    /// settings file's owner (project root for project settings, agent dir for
106    /// global settings). `skills` is accepted as a compatibility shorthand.
107    #[serde(default, alias = "skills")]
108    pub skill_dirs: Option<Vec<String>>,
109    /// Additional prompt-template directories/files. `prompts` is accepted as
110    /// a compatibility shorthand.
111    #[serde(default, alias = "prompts")]
112    pub prompt_dirs: Option<Vec<String>>,
113    /// Additional Rust cdylib extension directories. `extensions` is accepted
114    /// as a compatibility shorthand.
115    #[serde(default, alias = "extensions")]
116    pub extension_dirs: Option<Vec<String>>,
117    /// Native Pi keybinding overrides. Values may be a key string, an array
118    /// of key strings, or an empty array to unbind an action.
119    #[serde(default)]
120    pub keybindings: Option<HashMap<String, serde_json::Value>>,
121    /// Action performed by two quick Escape presses while the editor is empty.
122    /// Native Pi defaults this to `tree`; `none` disables the gesture.
123    #[serde(default)]
124    pub double_escape_action: Option<String>,
125    /// Default tools to enable at startup. When absent, all built-in tools
126    /// are enabled. When present, only the named tools are enabled.
127    #[serde(default)]
128    pub default_tools: Option<Vec<String>>,
129    /// Hide the body of thinking blocks while retaining a compact label.
130    #[serde(default)]
131    pub hide_thinking_block: Option<bool>,
132    /// Suppress the interactive startup header and resource summary.
133    /// Update checks remain enabled, matching native Pi.
134    #[serde(default)]
135    pub quiet_startup: Option<bool>,
136    /// Show the global terminal progress indicator while a run is active.
137    #[serde(default)]
138    pub show_terminal_progress: Option<bool>,
139    /// Horizontal editor padding in terminal columns.
140    #[serde(default)]
141    pub editor_padding_x: Option<usize>,
142    /// Maximum number of autocomplete rows shown above the editor.
143    #[serde(default)]
144    pub autocomplete_max_visible: Option<usize>,
145    /// Automatically copy text to clipboard when selected in fullscreen mode.
146    /// When false, text selection requires explicit copy action.
147    #[serde(default)]
148    pub fullscreen_copy_on_select: Option<bool>,
149    /// How long idle pooled HTTP connections are kept before being closed.
150    /// Accepts a millisecond number or the string `"disabled"`. Native pi
151    /// `httpIdleTimeout`.
152    #[serde(default)]
153    pub http_idle_timeout: Option<serde_json::Value>,
154}
155
156impl Settings {
157    /// Resolved HTTP idle timeout in milliseconds (`None` ⇒ library default,
158    /// `Some(0)` ⇒ disabled). See [`rpi_ai::http::parse_http_idle_timeout_ms`].
159    pub fn http_idle_timeout_ms(&self) -> Option<i64> {
160        self.http_idle_timeout
161            .as_ref()
162            .and_then(rpi_ai::http::parse_http_idle_timeout_ms)
163    }
164}
165
166/// Load `~/.rpi/agent/settings.json`. Missing file ⇒ `Settings::default()`
167/// (not an error). Malformed JSON ⇒ `ConfigError::Json`. Tolerates `//` line
168/// comments (a copied pi settings.json may contain them).
169pub fn load_settings() -> Result<Settings, ConfigError> {
170    let path = config::settings_path()?;
171    match std::fs::read_to_string(&path) {
172        Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
173            path: path.clone(),
174            source: e,
175        }),
176        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
177            // A native Pi installation keeps its settings under ~/.pi/agent.
178            // Read that file only as a fallback; all writes still target the
179            // rpi-owned ~/.rpi/agent/settings.json path.
180            let legacy = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
181                None
182            } else {
183                dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
184            };
185            match legacy.filter(|candidate| candidate != &path) {
186                Some(legacy_path) => match std::fs::read_to_string(&legacy_path) {
187                    Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
188                        path: legacy_path,
189                        source: e,
190                    }),
191                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
192                        Ok(Settings::default())
193                    }
194                    Err(error) => Err(ConfigError::Read {
195                        path: legacy_path,
196                        source: error,
197                    }),
198                },
199                None => Ok(Settings::default()),
200            }
201        }
202        Err(e) => Err(ConfigError::Read { path, source: e }),
203    }
204}
205
206/// Load the single active project settings document. `.rpi/settings.json`
207/// takes precedence; native Pi's `.pi/settings.json` is consulted only when
208/// the rpi-owned file does not exist. A malformed preferred file masks the
209/// fallback and yields no project settings, keeping project configuration
210/// fail-closed instead of executing entries from a stale lower-priority file.
211pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
212    load_project_settings_with_paths(cwd)
213        .into_iter()
214        .map(|(_, settings)| settings)
215        .collect()
216}
217
218/// Load the active project settings together with its source path.
219pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
220    load_active_project_settings(cwd)
221        .ok()
222        .flatten()
223        .into_iter()
224        .collect()
225}
226
227/// Load the active project settings while preserving parse/read failures for
228/// callers that must fail closed, such as package install/update preflight.
229pub fn load_active_project_settings(
230    cwd: &Path,
231) -> Result<Option<(PathBuf, Settings)>, ConfigError> {
232    let preferred = cwd.join(".rpi/settings.json");
233    match load_settings_file(&preferred) {
234        Ok(settings) => Ok(Some((preferred, settings))),
235        Err(ConfigError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
236            let fallback = cwd.join(".pi/settings.json");
237            match load_settings_file(&fallback) {
238                Ok(settings) => Ok(Some((fallback, settings))),
239                Err(ConfigError::Read { source, .. })
240                    if source.kind() == std::io::ErrorKind::NotFound =>
241                {
242                    Ok(None)
243                }
244                Err(error) => Err(error),
245            }
246        }
247        Err(error) => Err(error),
248    }
249}
250
251/// Load model-selection settings with native Pi's global -> trusted-project
252/// precedence. Only fields consumed during provider resolution are overlaid;
253/// package and resource loading keeps its own scope-aware merge semantics.
254pub fn load_effective_model_settings(
255    cwd: &Path,
256    project_trusted: bool,
257) -> Result<Settings, ConfigError> {
258    let mut effective = load_settings()?;
259    if project_trusted {
260        if let Some((_, project)) = load_active_project_settings(cwd)? {
261            if project.default_provider.is_some() {
262                effective.default_provider = project.default_provider;
263            }
264            if project.default_model.is_some() {
265                effective.default_model = project.default_model;
266            }
267            if project.default_thinking_level.is_some() {
268                effective.default_thinking_level = project.default_thinking_level;
269            }
270            if project.theme.is_some() {
271                effective.theme = project.theme;
272            }
273        }
274    }
275    Ok(effective)
276}
277
278/// Load the project settings document that rpi may safely update. The
279/// preferred `.rpi` file wins; when it does not exist, native Pi's `.pi` file
280/// seeds the first rpi-owned save so package changes do not discard fields rpi
281/// does not model.
282pub fn load_project_settings_for_write(cwd: &Path) -> Result<Settings, ConfigError> {
283    Ok(load_active_project_settings(cwd)?
284        .map(|(_, settings)| settings)
285        .unwrap_or_default())
286}
287
288fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
289    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
290        path: path.to_path_buf(),
291        source,
292    })?;
293    parse_settings(&text).map_err(|source| ConfigError::Json {
294        path: path.to_path_buf(),
295        source,
296    })
297}
298
299/// Resolve a configured path relative to its settings owner. Absolute paths
300/// are preserved; empty entries are ignored.
301pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
302    values
303        .iter()
304        .map(|value| value.trim())
305        .filter(|value| !value.is_empty())
306        .map(|value| {
307            let path = PathBuf::from(value);
308            if path.is_absolute() {
309                path
310            } else {
311                base.join(path)
312            }
313        })
314        .collect()
315}
316
317fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
318    match serde_json::from_str(text) {
319        Ok(s) => Ok(s),
320        Err(first) => {
321            let stripped = strip_line_comments(text);
322            serde_json::from_str(&stripped).map_err(|_| first)
323        }
324    }
325}
326
327/// Parse an existing settings document while retaining fields that rpi does
328/// not model. Native Pi permits `//` line comments in `settings.json`, so use
329/// the same strict-then-comment-stripped strategy as [`parse_settings`].
330/// Keeping this separate from `parse_settings` is important: deserializing
331/// into [`Settings`] would discard unknown fields before a save/merge.
332fn parse_settings_value(text: &str) -> Result<serde_json::Value, serde_json::Error> {
333    match serde_json::from_str::<serde_json::Value>(text) {
334        Ok(value) => Ok(value),
335        Err(first) => {
336            let stripped = strip_line_comments(text);
337            serde_json::from_str::<serde_json::Value>(&stripped).map_err(|_| first)
338        }
339    }
340}
341
342/// Persist the honored settings back to `~/.rpi/agent/settings.json`.
343/// Unknown pi fields (which `Settings` doesn't model) are **preserved**: the
344/// current file is read as raw JSON, the known fields are overlaid, and the
345/// merged object is written — so a copied pi `settings.json` survives edits
346/// without losing pi-only keys. `//` comments are accepted using the same
347/// parser as [`load_settings`]. Missing file ⇒ a fresh object. Existing files
348/// that cannot be parsed safely are left untouched and reported as errors.
349pub fn save_settings(settings: &Settings) -> Result<(), String> {
350    let path = config::settings_path().map_err(|e| e.to_string())?;
351    let fallback = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
352        None
353    } else {
354        dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
355    };
356    save_settings_to_path(settings, &path, fallback.as_deref())
357}
358
359/// Persist project-scoped settings under `.rpi/settings.json`, preserving an
360/// existing native `.pi/settings.json` as the raw fallback on the first save.
361pub fn save_project_settings(cwd: &Path, settings: &Settings) -> Result<(), String> {
362    save_settings_to_path(
363        settings,
364        &cwd.join(".rpi/settings.json"),
365        Some(&cwd.join(".pi/settings.json")),
366    )
367}
368
369fn save_settings_to_path(
370    settings: &Settings,
371    path: &Path,
372    fallback: Option<&Path>,
373) -> Result<(), String> {
374    // Read the existing file as a raw object to preserve unknown fields.
375    let mut merged = match std::fs::read_to_string(path) {
376        Ok(text) => parse_settings_value(&text).map_err(|error| {
377            format!(
378                "cannot parse existing settings file {}: {error}",
379                path.display()
380            )
381        })?,
382        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
383            read_fallback_settings_value(path, fallback)?
384                .unwrap_or_else(|| serde_json::Value::Object(Default::default()))
385        }
386        Err(error) => {
387            return Err(format!(
388                "cannot read existing settings file {}: {error}",
389                path.display()
390            ));
391        }
392    };
393    let obj = merged
394        .as_object_mut()
395        .ok_or_else(|| format!("settings file {} is not an object", path.display()))?;
396    for (key, val) in [
397        ("defaultProvider", settings.default_provider.as_ref()),
398        ("defaultModel", settings.default_model.as_ref()),
399        (
400            "defaultThinkingLevel",
401            settings.default_thinking_level.as_ref(),
402        ),
403        ("theme", settings.theme.as_ref()),
404    ] {
405        match val {
406            Some(v) => {
407                obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
408            }
409            None => {
410                obj.remove(key);
411            }
412        }
413    }
414    match &settings.scoped_models {
415        Some(list) if !list.is_empty() => {
416            obj.insert(
417                "scopedModels".to_string(),
418                serde_json::Value::Array(
419                    list.iter()
420                        .map(|m| serde_json::Value::String(m.clone()))
421                        .collect(),
422                ),
423            );
424        }
425        _ => {
426            obj.remove("scopedModels");
427        }
428    }
429    match &settings.packages {
430        Some(list) if !list.is_empty() => {
431            obj.insert(
432                "packages".to_string(),
433                serde_json::to_value(list).map_err(|e| e.to_string())?,
434            );
435        }
436        _ => {
437            obj.remove("packages");
438        }
439    }
440    match &settings.npm_command {
441        Some(command) => {
442            obj.insert(
443                "npmCommand".to_string(),
444                serde_json::to_value(command).map_err(|e| e.to_string())?,
445            );
446        }
447        None => {
448            obj.remove("npmCommand");
449        }
450    }
451    for (key, values) in [
452        ("skillDirs", settings.skill_dirs.as_ref()),
453        ("promptDirs", settings.prompt_dirs.as_ref()),
454        ("extensionDirs", settings.extension_dirs.as_ref()),
455    ] {
456        match values {
457            Some(list) if !list.is_empty() => {
458                obj.insert(
459                    key.to_string(),
460                    serde_json::Value::Array(
461                        list.iter()
462                            .map(|path| serde_json::Value::String(path.clone()))
463                            .collect(),
464                    ),
465                );
466            }
467            _ => {
468                obj.remove(key);
469            }
470        }
471    }
472    match &settings.keybindings {
473        Some(bindings) => {
474            obj.insert(
475                "keybindings".to_string(),
476                serde_json::to_value(bindings).map_err(|e| e.to_string())?,
477            );
478        }
479        None => {
480            obj.remove("keybindings");
481        }
482    }
483    match settings.double_escape_action.as_deref() {
484        Some(action) if !action.trim().is_empty() => {
485            obj.insert(
486                "doubleEscapeAction".to_string(),
487                serde_json::Value::String(action.to_string()),
488            );
489        }
490        _ => {
491            obj.remove("doubleEscapeAction");
492        }
493    }
494    for (key, value) in [
495        (
496            "hideThinkingBlock",
497            settings.hide_thinking_block.map(serde_json::Value::Bool),
498        ),
499        (
500            "quietStartup",
501            settings.quiet_startup.map(serde_json::Value::Bool),
502        ),
503        (
504            "showTerminalProgress",
505            settings.show_terminal_progress.map(serde_json::Value::Bool),
506        ),
507        (
508            "editorPaddingX",
509            settings
510                .editor_padding_x
511                .map(|v| serde_json::Value::Number(v.into())),
512        ),
513        (
514            "autocompleteMaxVisible",
515            settings
516                .autocomplete_max_visible
517                .map(|v| serde_json::Value::Number(v.into())),
518        ),
519    ] {
520        match value {
521            Some(value) => {
522                obj.insert(key.to_string(), value);
523            }
524            None => {
525                obj.remove(key);
526            }
527        }
528    }
529    if let Some(parent) = path.parent() {
530        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
531    }
532    let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
533    config::atomic_write(path, text.as_bytes()).map_err(|e| e.to_string())
534}
535
536/// When rpi is still reading native Pi's settings fallback, seed the first
537/// rpi-owned save from that complete raw object. Otherwise a modeled-field
538/// save could shadow the native file and silently drop fields added by Pi.
539fn read_fallback_settings_value(
540    target: &Path,
541    fallback: Option<&Path>,
542) -> Result<Option<serde_json::Value>, String> {
543    let Some(path) = fallback else {
544        return Ok(None);
545    };
546    if path == target {
547        return Ok(None);
548    }
549    match std::fs::read_to_string(&path) {
550        Ok(text) => parse_settings_value(&text).map(Some).map_err(|error| {
551            format!(
552                "cannot parse fallback settings file {}: {error}",
553                path.display()
554            )
555        }),
556        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
557        Err(error) => Err(format!(
558            "cannot read fallback settings file {}: {error}",
559            path.display()
560        )),
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::config::test_support::env_lock;
568
569    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for this test.
570    struct TempConfig {
571        _guard: std::sync::MutexGuard<'static, ()>,
572        _tmp: tempfile::TempDir,
573        prev: Option<std::ffi::OsString>,
574    }
575    impl TempConfig {
576        fn new() -> Self {
577            let guard = env_lock().lock().unwrap();
578            let prev = std::env::var_os(config::CONFIG_DIR_ENV);
579            let tmp = tempfile::TempDir::new().unwrap();
580            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
581            Self {
582                _guard: guard,
583                _tmp: tmp,
584                prev,
585            }
586        }
587    }
588    impl Drop for TempConfig {
589        fn drop(&mut self) {
590            match self.prev.take() {
591                Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
592                None => std::env::remove_var(config::CONFIG_DIR_ENV),
593            }
594        }
595    }
596
597    #[test]
598    fn missing_settings_is_default() {
599        let _cfg = TempConfig::new();
600        let s = load_settings().unwrap();
601        assert!(s.default_provider.is_none());
602        assert!(s.default_model.is_none());
603        assert!(s.default_thinking_level.is_none());
604        assert!(s.theme.is_none());
605        assert!(s.packages.is_none());
606        assert!(s.npm_command.is_none());
607        assert!(s.skill_dirs.is_none());
608        assert!(s.prompt_dirs.is_none());
609        assert!(s.extension_dirs.is_none());
610    }
611
612    #[test]
613    fn reads_honored_fields_and_ignores_unknown() {
614        let _cfg = TempConfig::new();
615        let path = config::settings_path().unwrap();
616        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
617        // A pi-style settings.json with many unknown fields + the 4 we honor.
618        std::fs::write(
619            &path,
620            r#"{
621                "lastChangelogVersion": "1.0.0",
622                "defaultProvider": "anthropic",
623                "defaultModel": "claude-sonnet-5",
624                "defaultThinkingLevel": "high",
625                "theme": "dark",
626                "hideThinkingBlock": true,
627                "quietStartup": true,
628                "showTerminalProgress": false,
629                "editorPaddingX": 3,
630                "autocompleteMaxVisible": 7,
631                "compaction": { "threshold": 100 },
632                "npmCommand": ["mise", "exec", "node@20", "--", "npm"],
633                "packages": [
634                    "some-pkg",
635                    {
636                        "source": "npm:filtered-pkg",
637                        "autoload": false,
638                        "extensions": ["dist/index.js"],
639                        "skills": ["skills/review"],
640                        "prompts": ["prompts/review.md"],
641                        "themes": ["themes/dark.json"],
642                        "futureFilter": { "enabled": true }
643                    }
644                ]
645            }"#,
646        )
647        .unwrap();
648        let s = load_settings().unwrap();
649        assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
650        assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
651        assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
652        assert_eq!(s.theme.as_deref(), Some("dark"));
653        assert_eq!(
654            s.npm_command.as_deref(),
655            Some(
656                ["mise", "exec", "node@20", "--", "npm"]
657                    .map(String::from)
658                    .as_slice()
659            )
660        );
661        let packages = s.packages.as_deref().unwrap();
662        assert_eq!(packages[0], PackageSetting::from("some-pkg"));
663        assert_eq!(packages[1].source(), "npm:filtered-pkg");
664        let PackageSetting::Filtered(filter) = &packages[1] else {
665            panic!("expected an object-form package setting");
666        };
667        assert_eq!(filter.autoload, Some(false));
668        assert_eq!(
669            filter.extensions.as_deref(),
670            Some(["dist/index.js".into()].as_slice())
671        );
672        assert_eq!(
673            filter.skills.as_deref(),
674            Some(["skills/review".into()].as_slice())
675        );
676        assert_eq!(
677            filter.prompts.as_deref(),
678            Some(["prompts/review.md".into()].as_slice())
679        );
680        assert_eq!(
681            filter.themes.as_deref(),
682            Some(["themes/dark.json".into()].as_slice())
683        );
684        assert_eq!(filter.unknown["futureFilter"]["enabled"], true);
685        assert_eq!(s.hide_thinking_block, Some(true));
686        assert_eq!(s.quiet_startup, Some(true));
687        assert_eq!(s.show_terminal_progress, Some(false));
688        assert_eq!(s.editor_padding_x, Some(3));
689        assert_eq!(s.autocomplete_max_visible, Some(7));
690    }
691
692    #[test]
693    fn rpi_project_settings_mask_native_pi_settings() {
694        let tmp = tempfile::tempdir().unwrap();
695        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
696        std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
697        std::fs::write(
698            tmp.path().join(".rpi/settings.json"),
699            r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
700        )
701        .unwrap();
702        std::fs::write(
703            tmp.path().join(".pi/settings.json"),
704            r#"{"skills":["pi-skills"],"extensionDirs":["pi-ext"]}"#,
705        )
706        .unwrap();
707
708        let settings = load_project_settings(tmp.path());
709        assert_eq!(settings.len(), 1);
710        assert_eq!(
711            settings[0].skill_dirs.as_deref(),
712            Some(["rpi-skills".to_string()].as_slice())
713        );
714        assert_eq!(
715            settings[0].extension_dirs.as_deref(),
716            Some(["rpi-ext".to_string()].as_slice())
717        );
718    }
719
720    #[test]
721    fn native_pi_project_settings_are_a_fallback() {
722        let tmp = tempfile::tempdir().unwrap();
723        std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
724        std::fs::write(
725            tmp.path().join(".pi/settings.json"),
726            r#"{"defaultProvider":"native-provider","defaultModel":"native-model"}"#,
727        )
728        .unwrap();
729
730        let settings = load_project_settings_with_paths(tmp.path());
731        assert_eq!(settings.len(), 1);
732        assert!(settings[0].0.ends_with(".pi/settings.json"));
733        assert_eq!(
734            settings[0].1.default_provider.as_deref(),
735            Some("native-provider")
736        );
737    }
738
739    #[test]
740    fn malformed_rpi_project_settings_mask_native_pi_fallback() {
741        let tmp = tempfile::tempdir().unwrap();
742        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
743        std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
744        std::fs::write(tmp.path().join(".rpi/settings.json"), "{ malformed").unwrap();
745        std::fs::write(
746            tmp.path().join(".pi/settings.json"),
747            r#"{"packages":["npm:must-not-load"]}"#,
748        )
749        .unwrap();
750
751        assert!(load_project_settings_with_paths(tmp.path()).is_empty());
752        assert!(load_active_project_settings(tmp.path()).is_err());
753    }
754
755    #[test]
756    fn trusted_project_model_defaults_override_global_defaults() {
757        let _cfg = TempConfig::new();
758        let global = config::settings_path().unwrap();
759        std::fs::create_dir_all(global.parent().unwrap()).unwrap();
760        std::fs::write(
761            global,
762            r#"{"defaultProvider":"global","defaultModel":"global-model","theme":"dark"}"#,
763        )
764        .unwrap();
765        let project = tempfile::tempdir().unwrap();
766        std::fs::create_dir_all(project.path().join(".rpi")).unwrap();
767        std::fs::write(
768            project.path().join(".rpi/settings.json"),
769            r#"{"defaultProvider":"project","defaultModel":"project-model"}"#,
770        )
771        .unwrap();
772
773        let trusted = load_effective_model_settings(project.path(), true).unwrap();
774        assert_eq!(trusted.default_provider.as_deref(), Some("project"));
775        assert_eq!(trusted.default_model.as_deref(), Some("project-model"));
776        assert_eq!(trusted.theme.as_deref(), Some("dark"));
777
778        let untrusted = load_effective_model_settings(project.path(), false).unwrap();
779        assert_eq!(untrusted.default_provider.as_deref(), Some("global"));
780        assert_eq!(untrusted.default_model.as_deref(), Some("global-model"));
781    }
782
783    #[test]
784    fn tolerates_line_comments() {
785        let _cfg = TempConfig::new();
786        let path = config::settings_path().unwrap();
787        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
788        std::fs::write(
789            &path,
790            "{\n  // my default\n  \"defaultModel\": \"glm-5\",\n  \"theme\": \"light\"\n}\n",
791        )
792        .unwrap();
793        let s = load_settings().unwrap();
794        assert_eq!(s.default_model.as_deref(), Some("glm-5"));
795        assert_eq!(s.theme.as_deref(), Some("light"));
796    }
797
798    #[test]
799    fn malformed_is_error() {
800        let _cfg = TempConfig::new();
801        let path = config::settings_path().unwrap();
802        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
803        std::fs::write(&path, "{ not json").unwrap();
804        assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
805    }
806}
807
808#[cfg(test)]
809mod scoped_tests {
810    use super::*;
811    use crate::config::test_support::env_lock;
812
813    fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
814        let guard = env_lock().lock().unwrap();
815        let tmp = tempfile::TempDir::new().unwrap();
816        std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
817        (tmp, guard)
818    }
819
820    #[test]
821    fn save_load_scoped_models_roundtrip() {
822        let (_tmp, _guard) = with_temp_env();
823        let mut s = Settings::default();
824        s.scoped_models = Some(vec!["a".into(), "b".into()]);
825        save_settings(&s).unwrap();
826        let loaded = load_settings().unwrap();
827        assert_eq!(
828            loaded.scoped_models,
829            Some(vec!["a".to_string(), "b".to_string()])
830        );
831        // Clearing removes the key.
832        let mut s2 = load_settings().unwrap();
833        s2.scoped_models = None;
834        save_settings(&s2).unwrap();
835        assert_eq!(load_settings().unwrap().scoped_models, None);
836    }
837
838    #[test]
839    fn save_uses_atomic_sibling_replacement() {
840        let (_tmp, _guard) = with_temp_env();
841        let path = config::settings_path().unwrap();
842        std::fs::write(&path, r#"{"theme":"dark","piOnlyField":true}"#).unwrap();
843
844        let mut settings = load_settings().unwrap();
845        settings.theme = Some("light".into());
846        save_settings(&settings).unwrap();
847
848        let saved: serde_json::Value =
849            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
850        assert_eq!(saved["theme"], "light");
851        assert_eq!(saved["piOnlyField"], true);
852        // `config::atomic_write` cleans up its sibling by replacing the
853        // target in one rename; a successful save leaves no staging file.
854        let temp = path.with_file_name(format!(
855            ".{}.tmp",
856            path.file_name().and_then(|name| name.to_str()).unwrap()
857        ));
858        assert!(!temp.exists(), "atomic staging file should not remain");
859    }
860
861    #[test]
862    fn save_preserves_unknown_fields() {
863        let (_tmp, _guard) = with_temp_env();
864        let path = config::settings_path().unwrap();
865        std::fs::write(
866            &path,
867            r#"{
868                "piOnlyField": "keep-me",
869                "theme": "dark",
870                "npmCommand": ["pnpm"],
871                "packages": [{
872                    "source": "npm:future-package",
873                    "autoload": false,
874                    "futureFilter": { "enabled": true }
875                }]
876            }"#,
877        )
878        .unwrap();
879        let mut s = load_settings().unwrap();
880        assert_eq!(s.npm_command, Some(vec!["pnpm".to_string()]));
881        s.scoped_models = Some(vec!["m1".into()]);
882        save_settings(&s).unwrap();
883        let raw: serde_json::Value =
884            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
885        assert_eq!(raw["piOnlyField"], "keep-me");
886        assert_eq!(raw["scopedModels"][0], "m1");
887        assert_eq!(raw["theme"], "dark");
888        assert_eq!(raw["npmCommand"][0], "pnpm");
889        assert_eq!(raw["packages"][0]["source"], "npm:future-package");
890        assert_eq!(raw["packages"][0]["autoload"], false);
891        assert_eq!(raw["packages"][0]["futureFilter"]["enabled"], true);
892    }
893
894    #[test]
895    fn save_preserves_unknown_fields_and_packages_with_line_comments() {
896        let (_tmp, _guard) = with_temp_env();
897        let path = config::settings_path().unwrap();
898        let original = r#"{
899            // Native Pi permits comments in settings files.
900            "piOnlyField": { "keep": true },
901            "theme": "dark",
902            "packages": [
903                // Keep the package object and fields that rpi does not use.
904                {
905                    "source": "npm:future-package",
906                    "autoload": false,
907                    "futureFilter": { "enabled": true }
908                }
909            ]
910        }
911        "#;
912        std::fs::write(&path, original).unwrap();
913
914        let mut settings = load_settings().unwrap();
915        settings.scoped_models = Some(vec!["m1".into()]);
916        save_settings(&settings).unwrap();
917
918        // The comments may be normalized away by pretty-printing, but every
919        // unknown field and package property must survive the merge.
920        let raw: serde_json::Value =
921            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
922        assert_eq!(raw["piOnlyField"]["keep"], true);
923        assert_eq!(raw["theme"], "dark");
924        assert_eq!(raw["packages"][0]["source"], "npm:future-package");
925        assert_eq!(raw["packages"][0]["autoload"], false);
926        assert_eq!(raw["packages"][0]["futureFilter"]["enabled"], true);
927        assert_eq!(raw["scopedModels"][0], "m1");
928    }
929
930    #[test]
931    fn save_fails_closed_for_unparseable_existing_settings() {
932        let (_tmp, _guard) = with_temp_env();
933        let path = config::settings_path().unwrap();
934        // This is not recoverable by stripping comments (the closing brace is
935        // missing). Saving must leave the user's file byte-for-byte intact.
936        let original = b"{\n  // keep this file intact\n  \"piOnlyField\": \"keep-me\"\n";
937        std::fs::write(&path, original).unwrap();
938
939        let mut settings = Settings::default();
940        settings.theme = Some("light".into());
941        let error =
942            save_settings(&settings).expect_err("malformed settings must not be overwritten");
943        assert!(error.contains("cannot parse existing settings file"));
944        assert_eq!(std::fs::read(&path).unwrap(), original);
945    }
946
947    #[test]
948    fn project_save_seeds_from_native_pi_without_losing_unknown_fields() {
949        let tmp = tempfile::tempdir().unwrap();
950        let legacy = tmp.path().join(".pi/settings.json");
951        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
952        std::fs::write(
953            &legacy,
954            r#"{
955                // Preserve fields from native Pi on the first rpi save.
956                "piOnlyField": { "keep": true },
957                "packages": ["npm:existing"]
958            }"#,
959        )
960        .unwrap();
961
962        let mut settings = load_project_settings_for_write(tmp.path()).unwrap();
963        settings.theme = Some("dark".into());
964        save_project_settings(tmp.path(), &settings).unwrap();
965
966        let preferred = tmp.path().join(".rpi/settings.json");
967        let saved: serde_json::Value =
968            serde_json::from_str(&std::fs::read_to_string(preferred).unwrap()).unwrap();
969        assert_eq!(saved["piOnlyField"]["keep"], true);
970        assert_eq!(saved["packages"][0], "npm:existing");
971        assert_eq!(saved["theme"], "dark");
972    }
973
974    #[test]
975    fn project_save_fails_closed_for_malformed_native_fallback() {
976        let tmp = tempfile::tempdir().unwrap();
977        let legacy = tmp.path().join(".pi/settings.json");
978        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
979        std::fs::write(&legacy, "{ malformed").unwrap();
980
981        assert!(load_project_settings_for_write(tmp.path()).is_err());
982        assert!(!tmp.path().join(".rpi/settings.json").exists());
983    }
984}