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