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