Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
//! 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<PathBuf> {
|
||||
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<OsString>) -> Option<PathBuf> {
|
||||
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<BTreeMap<String, String>> {
|
||||
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<String, String>) -> 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<OsString> + '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<String, String> = ron::from_str(&read_back).unwrap();
|
||||
assert_eq!(parsed, map);
|
||||
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user