//! Editor-wide preferences persistence on disk. //! //! The Stage-6 [`Settings`](oxide_engine::settings::Settings) framework //! defines *what* is persisted (named sections, each owning a typed value). //! This module defines *where* — the user-scoped file the editor reads on //! startup and writes on every change, so a binding remap or theme tweak //! survives a restart. //! //! # Location //! //! Linux: `$XDG_CONFIG_HOME/oxide/editor.ron`, falling back to //! `$HOME/.config/oxide/editor.ron`. The directory is created on demand; //! the path is the same one a Windows port would use once Stage-16 ships //! game export (Windows resolution lands then, not here). //! //! # Format //! //! The file is exactly the RON map [`Settings::export`] produces: //! `{ "section.name": "(field: value, …)", … }`. Each value is itself a //! RON-encoded string of that section's typed value. Loading does no //! schema validation — unknown sections are skipped by `Settings::import`, //! so removing a section in code never breaks an old file. use std::collections::BTreeMap; use std::ffi::OsString; use std::io; use std::path::PathBuf; /// Resolves the absolute path to the editor's preferences file, or `None` /// if the OS provides no usable home / config directory (a stripped-down /// container, an unusual launcher environment, …). pub fn config_path() -> Option { resolve_config_path(|k| std::env::var_os(k)) } /// Resolution rules, factored so tests can inject env state without racing /// on the real process environment. Returns the first of: /// /// 1. `$XDG_CONFIG_HOME/oxide/editor.ron` /// 2. `$HOME/.config/oxide/editor.ron` /// 3. `None` if neither is set. fn resolve_config_path(env: impl Fn(&str) -> Option) -> Option { let base = env("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")))?; Some(base.join("oxide").join("editor.ron")) } /// Loads the preferences file, returning the same `BTreeMap` shape /// [`Settings::import`](oxide_engine::settings::Settings::import) consumes. /// /// Returns `None` when no file exists yet (a fresh install) or it can't be /// parsed — both cases are silently treated as "no saved preferences" so /// the editor falls back to the code-defined defaults. A returned `Some` /// is the file's contents verbatim; the caller decides what to import. pub fn load() -> Option> { let path = config_path()?; let text = std::fs::read_to_string(&path).ok()?; ron::from_str(&text).ok() } /// Writes `map` to the preferences file, creating the parent directory if /// necessary. The map is the output of /// [`Settings::export`](oxide_engine::settings::Settings::export); the /// editor calls this from the host runner whenever a binding edit or /// other settings change flips a dirty flag. pub fn save(map: &BTreeMap) -> io::Result<()> { let path = config_path().ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, "no $XDG_CONFIG_HOME or $HOME — cannot resolve editor preferences path", ) })?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let text = ron::ser::to_string_pretty(map, ron::ser::PrettyConfig::default()) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; std::fs::write(path, text) } #[cfg(test)] mod tests { use super::*; /// A throw-away env stub built from a closure — keeps each test free of /// process-global env mutation, so the suite can run in parallel. fn env<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { move |k| { map.iter() .find(|(kk, _)| *kk == k) .map(|(_, v)| OsString::from(*v)) } } #[test] fn config_path_uses_xdg_when_set() { let p = resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x")])).unwrap(); assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron")); } #[test] fn config_path_prefers_xdg_over_home_when_both_set() { let p = resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x"), ("HOME", "/tmp/h")])).unwrap(); assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron")); } #[test] fn config_path_falls_back_to_home_dot_config() { let p = resolve_config_path(env(&[("HOME", "/tmp/h")])).unwrap(); assert_eq!(p, PathBuf::from("/tmp/h/.config/oxide/editor.ron")); } #[test] fn config_path_is_none_when_no_env_available() { let p = resolve_config_path(env(&[])); assert!(p.is_none()); } #[test] fn save_then_load_round_trips_the_exported_map() { // Direct file I/O test that doesn't go through config_path — write // to a temp file with a known shape and confirm the RON round-trip // matches what `Settings::export` produces. let scratch = std::env::temp_dir().join(format!( "oxide_editor_prefs_roundtrip_{}.ron", std::process::id() )); let _ = std::fs::remove_file(&scratch); let mut map = BTreeMap::new(); map.insert( "input.bindings".to_string(), "(bindings: {\"Jump\": [Key(KeyW)]})".to_string(), ); let text = ron::ser::to_string_pretty(&map, ron::ser::PrettyConfig::default()).unwrap(); std::fs::write(&scratch, &text).unwrap(); let read_back = std::fs::read_to_string(&scratch).unwrap(); let parsed: BTreeMap = ron::from_str(&read_back).unwrap(); assert_eq!(parsed, map); let _ = std::fs::remove_file(&scratch); } }