Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
@@ -0,0 +1,94 @@
//! Implementation of the [`UserPreferences`] trait using a file for
//! persistence.
use std::path::{Path, PathBuf};
use super::{in_memory::InMemoryPreferences, Error};
/// An implementation of the [`UserPreferences`] trait using a file for
/// persistence.
///
/// Note that this is currently not robust to external modifications to
/// the backing file (either by another instance of the application or
/// by an end user). This reads the file once when initialized and keeps
/// an in-memory copy of the preferences, flushing to disk after each
/// update.
pub struct FileBackedUserPreferences {
/// The path to the file that backs this preferences store.
file_path: PathBuf,
/// A backing in-memory preferences store that we can flush to disk upon
/// modification.
inner: InMemoryPreferences,
}
impl FileBackedUserPreferences {
/// Constructs a new file-backed user preferences store.
///
/// If no file exists at the given path, an empty in-memory backing store
/// will be used, and any modifications will trigger creation of the file
/// (including any missing parent directories).
///
/// Returns an error if something went wrong while attempting to read the
/// existing persisted preferences at the given path.
pub fn new(file_path: PathBuf) -> Result<Self, Error> {
let inner = Self::initialize_in_memory_preferences(file_path.as_path())?;
Ok(Self { file_path, inner })
}
/// Loads the contents of the file at the given path into an in-memory
/// preferences store.
///
/// If the file is not found, an empty store is returned. The file is
/// not created.
fn initialize_in_memory_preferences(file_path: &Path) -> Result<InMemoryPreferences, Error> {
let file_contents = match std::fs::read_to_string(file_path) {
Ok(file_contents) => file_contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(InMemoryPreferences::default());
}
Err(err) => return Err(err.into()),
};
// If the file is empty/only whitespace, proceed with the default preferences.
if file_contents.trim().is_empty() {
return Ok(InMemoryPreferences::default());
}
let prefs = serde_json::from_str(&file_contents).map_err(anyhow::Error::new);
if let Err(err) = &prefs {
log::warn!("Failed to deserialize file preferences: {err:#}");
}
Ok(prefs?)
}
/// Flushes the internal in-memory preferences store to disk.
fn flush(&self) -> Result<(), Error> {
let parent_dir = self
.file_path
.parent()
.expect("absolute path to file should have parent");
std::fs::create_dir_all(parent_dir)?;
let data = serde_json::to_string_pretty(&self.inner).map_err(anyhow::Error::new)?;
std::fs::write(&self.file_path, data)?;
Ok(())
}
}
impl super::UserPreferences for FileBackedUserPreferences {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
self.inner.write_value(key, value)?;
self.flush()
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
self.inner.read_value(key)
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
self.inner.remove_value(key)?;
self.flush()
}
}
@@ -0,0 +1,25 @@
use std::{cell::RefCell, collections::HashMap};
use serde::{Deserialize, Serialize};
/// A non-persisted, memory-backed user preferences store.
#[derive(Default, Serialize, Deserialize)]
pub struct InMemoryPreferences {
prefs: RefCell<HashMap<String, String>>,
}
impl super::UserPreferences for InMemoryPreferences {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
self.prefs.borrow_mut().insert(key.to_owned(), value);
Ok(())
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
Ok(self.prefs.borrow().get(key).map(ToOwned::to_owned))
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
let _ = self.prefs.borrow_mut().remove(key);
Ok(())
}
}
@@ -0,0 +1,30 @@
use super::UserPreferences;
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
/// An implementation of the [`UserPreferences`] trait using the local storage
/// property of the Web Storage API for persistence.
///
/// See: https://developer.mozilla.org/en-US/docs/Web/API/Storage
#[derive(Default)]
pub struct LocalStoragePreferences;
impl UserPreferences for LocalStoragePreferences {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
LocalStorage::set(key, value)
.map_err(anyhow::Error::from)
.map_err(super::Error::from)
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
match LocalStorage::get(key) {
Ok(val) => Ok(Some(val)),
Err(StorageError::KeyNotFound(_)) => Ok(None),
Err(e) => Err(super::Error::from(anyhow::Error::from(e))),
}
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
LocalStorage::delete(key);
Ok(())
}
}
@@ -0,0 +1,132 @@
//! Storage for user preferences.
pub mod file_backed;
pub mod in_memory;
#[cfg(target_family = "wasm")]
pub mod local_storage;
#[cfg(target_os = "windows")]
pub mod registry_backed;
#[cfg(feature = "user_preferences-toml")]
pub mod toml_backed;
#[cfg(target_os = "macos")]
pub mod user_defaults;
/// A type alias for a boxed user preferences backend.
pub type Model = Box<dyn UserPreferences>;
/// A trait representing storage for user preferences.
pub trait UserPreferences {
/// Writes a value at the given key.
fn write_value(&self, key: &str, value: String) -> Result<(), Error>;
/// Reads the value stored at the given key.
///
/// Returns Ok(None) if no value was found.
fn read_value(&self, key: &str) -> Result<Option<String>, Error>;
/// Removes the value stored at the given key, if any.
fn remove_value(&self, key: &str) -> Result<(), Error>;
/// Writes a value at the given key, with optional hierarchy context.
///
/// Hierarchy-aware backends (like TOML) use the hierarchy to place the
/// value in the correct section. The default implementation ignores
/// the hierarchy and delegates to [`write_value`](Self::write_value).
///
/// `max_table_depth` controls how deeply nested objects are rendered as
/// section tables before switching to inline tables:
/// - `None` — unlimited depth (all section tables)
/// - `Some(0)` — fully inline (`key = { ... }`)
/// - `Some(n)` — `n` levels of section tables, then inline
fn write_value_with_hierarchy(
&self,
key: &str,
value: String,
hierarchy: Option<&str>,
max_table_depth: Option<u32>,
) -> Result<(), Error> {
let _ = (hierarchy, max_table_depth);
self.write_value(key, value)
}
/// Reads the value stored at the given key, with optional hierarchy context.
///
/// The default implementation ignores the hierarchy and delegates to
/// [`read_value`](Self::read_value).
fn read_value_with_hierarchy(
&self,
key: &str,
hierarchy: Option<&str>,
) -> Result<Option<String>, Error> {
let _ = hierarchy;
self.read_value(key)
}
/// Removes the value stored at the given key, with optional hierarchy context.
///
/// The default implementation ignores the hierarchy and delegates to
/// [`remove_value`](Self::remove_value).
fn remove_value_with_hierarchy(&self, key: &str, hierarchy: Option<&str>) -> Result<(), Error> {
let _ = hierarchy;
self.remove_value(key)
}
/// Returns whether this backend is the user-visible settings file.
///
/// When true, settings that define custom file serialization (via
/// `file_serialize` / `file_deserialize`) will use their custom format
/// instead of the standard serde representation. This produces a more
/// human-readable settings file.
///
/// Other backends (NSUserDefaults, in-memory, etc.) return `false` and
/// always use the standard serde format.
fn is_settings_file(&self) -> bool {
false
}
/// Reloads the backing store from disk.
///
/// File-backed backends re-read their file and replace the in-memory
/// contents. Non-file backends do nothing. On parse failure the
/// implementation should keep the previous state and return an error.
fn reload_from_disk(&self) -> Result<(), Error> {
Ok(())
}
/// Marks a key as write-inhibited so that subsequent writes and removes
/// for this key are silently skipped.
///
/// This is used to protect individual setting values that exist in the
/// backing store but could not be deserialized into the expected type.
/// The user's broken-but-fixable value is preserved until they correct
/// it in the file.
///
/// The default implementation is a no-op (non-file backends don't need
/// per-key inhibition).
fn inhibit_writes_for_key(&self, key: &str, hierarchy: Option<&str>) {
let _ = (key, hierarchy);
}
/// Clears all per-key write inhibitions.
///
/// Called after a successful reload from disk so that inhibitions can
/// be re-derived from the freshly loaded values.
fn clear_all_write_inhibitions(&self) {}
}
/// Enumerates the various errors that can occur when interacting with user
/// preferences.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Failed to decode the stored bytes into a UTF-8 string.
#[error("failed to decode UTF-8 string from bytes")]
DecodeError(#[from] std::str::Utf8Error),
/// Generic I/O error.
#[error("i/o error")]
IoError(#[from] std::io::Error),
/// Catch-all for unclassifiable errors.
#[error("unknown error")]
Unknown(#[from] anyhow::Error),
}
@@ -0,0 +1,52 @@
use std::io;
/// Store user preferences in the Windows Registry.
/// Modeled after https://github.com/neovide/neovide/blob/main/src/windows_utils.rs .
use super::UserPreferences;
use windows_registry::{Key, CURRENT_USER};
use windows_result::HRESULT;
pub struct RegistryBackedPreferences {
app_key_path: String,
}
static WARP_REGISTRY_BASE_PATH: &str = "Software\\Warp.dev\\";
pub const KEY_NOT_FOUND_ERR: HRESULT = HRESULT::from_win32(0x80070002);
impl RegistryBackedPreferences {
/// Construct a separate registry path for each channel (stable, dev, local, etc.)
pub fn new(app_name: &str) -> Self {
Self {
app_key_path: WARP_REGISTRY_BASE_PATH.to_owned() + app_name,
}
}
/// Gets Warp's registry key, creating it if it does not already exist.
fn get_warp_registry(&self) -> Result<Key, super::Error> {
CURRENT_USER.create(self.app_key_path.clone()).map_err(|e| {
log::error!("unable to access Warp app key in Windows Registry: {e:#}");
super::Error::IoError(io::Error::from(e))
})
}
}
impl UserPreferences for RegistryBackedPreferences {
fn read_value(&self, name: &str) -> Result<Option<String>, super::Error> {
Ok(self.get_warp_registry()?.get_string(name).ok())
}
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
self.get_warp_registry()?
.set_string(key, value.as_str())
.map_err(|e| super::Error::from(io::Error::from(e)))
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
match self.get_warp_registry()?.remove_value(key) {
Ok(_) => Ok(()),
// If the key doesn't exist, then treat removal of that nonexistent key as a success.
Err(e) if e.code() == KEY_NOT_FOUND_ERR => Ok(()),
Err(e) => Err(super::Error::from(io::Error::from(e))),
}
}
}
@@ -0,0 +1,663 @@
//! Implementation of the [`UserPreferences`] trait using a TOML file for
//! persistence, with support for hierarchical sections and snake_case keys.
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, Value};
use super::Error;
/// Indentation used per nesting level when pretty-printing multi-line arrays
/// and inline tables.
const INDENT: &str = " ";
/// Rough line-length budget before an inline container breaks across lines.
///
/// Arrays and inline tables whose default single-line rendering exceeds this
/// width get pretty-printed across multiple lines. This is measured against
/// the longest line of the container's current rendering, so already-broken
/// children don't defeat the check.
const MAX_INLINE_WIDTH: usize = 100;
/// An implementation of the [`UserPreferences`] trait using a TOML file for
/// persistence.
///
/// Settings are organized into hierarchical sections based on the `hierarchy`
/// metadata from the `Setting` trait. Keys are automatically converted from
/// CamelCase to snake_case for idiomatic TOML.
///
/// Values are stored as native TOML types (booleans, integers, floats, strings)
/// rather than JSON-encoded strings, making the file human-readable and
/// hand-editable.
pub struct TomlBackedUserPreferences {
/// The path to the TOML file that backs this preferences store.
file_path: PathBuf,
/// The in-memory TOML document, preserving formatting and comments.
document: RefCell<DocumentMut>,
/// When `true`, writes are silently skipped to avoid overwriting a
/// broken settings file with defaults. Set when the initial parse
/// fails; cleared when [`reload_from_disk`](Self::reload_from_disk)
/// succeeds.
write_inhibited: Cell<bool>,
/// Storage keys whose writes are individually inhibited because the
/// value in the TOML file could not be deserialized into the expected
/// type. Writes and removes for these keys are silently skipped to
/// preserve the user's broken-but-fixable value.
///
/// Cleared on successful [`reload_from_disk`](Self::reload_from_disk)
/// and re-derived by the settings reload logic.
write_inhibited_keys: RefCell<HashSet<String>>,
}
impl TomlBackedUserPreferences {
/// Constructs a new TOML-backed user preferences store.
///
/// If no file exists at the given path, an empty document will be used,
/// and any modifications will trigger creation of the file (including
/// any missing parent directories).
///
/// If the file exists but contains invalid TOML, the store is created
/// with an empty document (so all settings fall back to defaults) and
/// the parse error is returned in the second tuple element. This
/// ensures the caller always gets a functional preferences backend
/// that can recover via [`reload_from_disk`](Self::reload_from_disk)
/// when the user fixes the file.
pub fn new(file_path: PathBuf) -> (Self, Option<Error>) {
let (document, write_inhibited, error) = match Self::load_document(file_path.as_path()) {
Ok(doc) => (doc, false, None),
Err(err) => {
log::warn!(
"Failed to parse settings file at {}: {err}; starting with empty defaults",
file_path.display(),
);
(DocumentMut::new(), true, Some(err))
}
};
(
Self {
file_path,
document: RefCell::new(document),
write_inhibited: Cell::new(write_inhibited),
write_inhibited_keys: RefCell::new(HashSet::new()),
},
error,
)
}
/// Loads the TOML document from disk, or returns an empty document if
/// the file doesn't exist.
fn load_document(file_path: &Path) -> Result<DocumentMut, Error> {
let file_contents = match std::fs::read_to_string(file_path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(DocumentMut::new());
}
Err(err) => return Err(err.into()),
};
if file_contents.trim().is_empty() {
return Ok(DocumentMut::new());
}
file_contents
.parse::<DocumentMut>()
.map_err(|err| Error::Unknown(anyhow::anyhow!(err)))
}
/// Hashes the settings file content on disk.
///
/// Returns `None` if the file is missing, empty/whitespace-only, or
/// unreadable. These cases are all treated as "no local state" rather
/// than "local state that should win" — the caller's startup
/// comparison logic treats a `None` result as "no differing local
/// state" so that cloud can restore rather than wiping cloud with
/// local defaults.
///
/// Uses SHA-256 so that persisted hashes are stable across Rust
/// toolchain upgrades and crate version bumps (unlike `SipHasher`
/// or `DefaultHasher`, whose output is not guaranteed to be stable).
pub fn file_content_hash(file_path: &Path) -> Option<String> {
let contents = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
Err(err) => {
log::warn!(
"Failed to read settings file at {}: {err}",
file_path.display()
);
return None;
}
};
// An empty/whitespace-only file is semantically equivalent to a
// missing file — no settings are defined. Treating them the
// same way avoids wiping cloud with defaults if the user
// empties the file to reset.
if contents.trim().is_empty() {
return None;
}
let digest = Sha256::digest(contents.as_bytes());
Some(format!("{digest:x}"))
}
/// Reloads the TOML document from disk, replacing the in-memory contents.
///
/// On parse failure (e.g. the user introduced a syntax error), the old
/// document is kept and an error is returned.
pub fn reload_from_disk(&self) -> Result<(), Error> {
match Self::load_document(self.file_path.as_path()) {
Ok(doc) => {
*self.document.borrow_mut() = doc;
// The file is now valid — allow writes again.
self.write_inhibited.set(false);
// Clear per-key inhibitions; they will be re-derived by
// the settings reload logic for keys that still fail.
self.write_inhibited_keys.borrow_mut().clear();
Ok(())
}
Err(err) => {
log::warn!(
"Failed to reload settings file at {}: {err}; keeping previous state",
self.file_path.display(),
);
Err(err)
}
}
}
/// Builds a compound key from a storage key and optional hierarchy.
///
/// For example, `("FontSize", Some("font"))` → `"font.FontSize"`.
fn compound_key(key: &str, hierarchy: Option<&str>) -> String {
match hierarchy {
Some(h) => format!("{h}.{key}"),
None => key.to_owned(),
}
}
/// Returns `true` if writes for the given key are individually inhibited.
fn is_key_write_inhibited(&self, key: &str, hierarchy: Option<&str>) -> bool {
let compound = Self::compound_key(key, hierarchy);
self.write_inhibited_keys.borrow().contains(&compound)
}
/// Flushes the in-memory TOML document to disk.
///
/// When writes are inhibited (because the initial parse failed), this
/// is a silent no-op to avoid overwriting the user's broken-but-fixable
/// file with empty defaults.
fn flush(&self) -> Result<(), Error> {
if self.write_inhibited.get() {
return Ok(());
}
let parent_dir = self
.file_path
.parent()
.expect("absolute path to file should have parent");
std::fs::create_dir_all(parent_dir)?;
let data = self.document.borrow().to_string();
std::fs::write(&self.file_path, data)?;
Ok(())
}
/// Navigates to or creates the table for the given hierarchy path.
///
/// For example, `"font.display"` will ensure that `[font.display]` exists
/// and return a mutable reference to that table.
fn get_or_create_table<'a>(table: &'a mut Table, hierarchy: &str) -> &'a mut Table {
let mut current = table;
for segment in hierarchy.split('.') {
if !current.contains_key(segment) || !current[segment].is_table() {
current[segment] = Item::Table(Table::new());
}
current = current[segment]
.as_table_mut()
.expect("just ensured this is a table");
}
current
}
/// Navigates to the table for the given hierarchy path, returning `None`
/// if any segment along the path doesn't exist or isn't a table.
fn get_table<'a>(table: &'a Table, hierarchy: &str) -> Option<&'a Table> {
let mut current = table;
for segment in hierarchy.split('.') {
current = current.get(segment)?.as_table()?;
}
Some(current)
}
/// Converts a JSON-serialized value string into a native TOML [`Item`].
///
/// Primitives become native TOML types, JSON objects become TOML tables,
/// JSON arrays become TOML arrays (with inline tables for object elements),
/// and JSON null is omitted (`Item::None`).
///
/// `remaining_depth` controls how deeply nested objects are rendered as
/// section tables before switching to inline tables:
/// - `None` — unlimited depth (all section tables)
/// - `Some(0)` — fully inline (`{ key = value }`)
/// - `Some(n)` — `n` levels of section tables, then inline
fn json_value_to_toml_item(json_str: &str, remaining_depth: Option<u32>) -> Item {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(json_str) {
if remaining_depth == Some(0) {
match Self::json_to_toml_value(&json_value) {
Some(v) => Item::Value(v),
None => Item::None,
}
} else {
Self::json_to_toml_item(&json_value, remaining_depth)
}
} else {
// If it's not valid JSON, store as a plain string.
value(json_str)
}
}
/// Recursively converts a parsed JSON value into a TOML [`Item`].
///
/// JSON objects become `Item::Table` (rendered as `[section]` headers),
/// which is more readable than inline tables for struct-valued settings.
///
/// `remaining_depth` controls how many more levels of section tables to
/// allow before switching to inline. `None` means unlimited.
fn json_to_toml_item(json: &serde_json::Value, remaining_depth: Option<u32>) -> Item {
match json {
serde_json::Value::Null => Item::None,
serde_json::Value::Bool(b) => value(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
value(i)
} else if let Some(f) = n.as_f64() {
value(f)
} else {
value(n.to_string())
}
}
serde_json::Value::String(s) => value(s.as_str()),
serde_json::Value::Array(arr) => {
let mut toml_arr = toml_edit::Array::new();
for elem in arr {
if let Some(v) = Self::json_to_toml_value(elem) {
toml_arr.push(v);
}
}
value(toml_arr)
}
serde_json::Value::Object(obj) => {
let child_depth = remaining_depth.map(|d| d.saturating_sub(1));
if child_depth == Some(0) {
// Children should be inline — convert each value inline.
let mut table = Table::new();
for (k, v) in obj {
let item = match Self::json_to_toml_value(v) {
Some(v) => Item::Value(v),
None => Item::None,
};
if !matches!(item, Item::None) {
table[k.as_str()] = item;
}
}
Item::Table(table)
} else {
let mut table = Table::new();
for (k, v) in obj {
let item = Self::json_to_toml_item(v, child_depth);
if !matches!(item, Item::None) {
table[k.as_str()] = item;
}
}
Item::Table(table)
}
}
}
}
/// Recursively pretty-prints an [`Item`] tree in place.
///
/// Inline containers (arrays and inline tables) whose single-line
/// rendering exceeds [`MAX_INLINE_WIDTH`], or which contain a child that
/// itself was made multi-line, get broken across lines using [`INDENT`]
/// per nesting level. Other items are left untouched.
///
/// This is only called on freshly-built items produced by
/// [`Self::json_value_to_toml_item`], so there's no risk of trampling
/// over user-authored formatting for other entries in the file.
fn prettify_item(item: &mut Item, indent_level: usize) {
match item {
Item::None => {}
Item::Value(v) => Self::prettify_value(v, indent_level),
Item::Table(t) => Self::prettify_table(t, indent_level),
Item::ArrayOfTables(arr) => {
for table in arr.iter_mut() {
Self::prettify_table(table, indent_level);
}
}
}
}
fn prettify_value(v: &mut Value, indent_level: usize) {
match v {
Value::Array(arr) => Self::prettify_array(arr, indent_level),
Value::InlineTable(t) => Self::prettify_inline_table(t, indent_level),
Value::String(_)
| Value::Integer(_)
| Value::Float(_)
| Value::Boolean(_)
| Value::Datetime(_) => {}
}
}
fn prettify_table(t: &mut Table, indent_level: usize) {
// A section table's header sits at column 0 regardless of how deeply
// nested it is logically, and so do its `key = value` lines. So the
// effective indent for its children is the same as its own.
for (_, item) in t.iter_mut() {
Self::prettify_item(item, indent_level);
}
}
fn prettify_array(arr: &mut Array, indent_level: usize) {
// Recurse into children first so their multi-line decisions are
// final before we look at the parent.
for v in arr.iter_mut() {
Self::prettify_value(v, indent_level + 1);
}
if arr.is_empty() {
return;
}
let needs_multiline = arr.iter().any(Self::value_rendering_is_multiline)
|| Self::longest_line(&arr.to_string()) > MAX_INLINE_WIDTH;
if !needs_multiline {
return;
}
let child_indent = INDENT.repeat(indent_level + 1);
let outer_indent = INDENT.repeat(indent_level);
let child_prefix = format!("\n{child_indent}");
for v in arr.iter_mut() {
v.decor_mut().set_prefix(child_prefix.clone());
v.decor_mut().set_suffix("");
}
arr.set_trailing_comma(true);
arr.set_trailing(format!("\n{outer_indent}"));
}
fn prettify_inline_table(t: &mut InlineTable, indent_level: usize) {
for (_, v) in t.iter_mut() {
Self::prettify_value(v, indent_level + 1);
}
if t.is_empty() {
return;
}
let needs_multiline = t.iter().any(|(_, v)| Self::value_rendering_is_multiline(v))
|| Self::longest_line(&t.to_string()) > MAX_INLINE_WIDTH;
if !needs_multiline {
return;
}
let child_indent = INDENT.repeat(indent_level + 1);
let outer_indent = INDENT.repeat(indent_level);
let child_prefix = format!("\n{child_indent}");
for (mut key, v) in t.iter_mut() {
key.leaf_decor_mut().set_prefix(child_prefix.clone());
key.leaf_decor_mut().set_suffix(" ");
v.decor_mut().set_prefix(" ");
v.decor_mut().set_suffix("");
}
t.set_trailing_comma(true);
t.set_trailing(format!("\n{outer_indent}"));
}
/// Whether a value's current rendering spans multiple lines.
///
/// Used for the propagation rule: if any child container was already
/// expanded, the parent must be expanded too to avoid ugly output like
/// `[{\n a = 1\n}, ...]`.
fn value_rendering_is_multiline(v: &Value) -> bool {
match v {
Value::Array(_) | Value::InlineTable(_) => v.to_string().contains('\n'),
Value::String(_)
| Value::Integer(_)
| Value::Float(_)
| Value::Boolean(_)
| Value::Datetime(_) => false,
}
}
/// Returns the length (in chars) of the longest line in `s`.
fn longest_line(s: &str) -> usize {
s.lines()
.map(|line| line.chars().count())
.max()
.unwrap_or(0)
}
/// Converts a parsed JSON value into a TOML [`Value`](toml_edit::Value).
///
/// Objects become inline tables (`key = { ... }`), keeping setting values
/// on a single line rather than creating separate `[section]` headers.
fn json_to_toml_value(json: &serde_json::Value) -> Option<toml_edit::Value> {
match json {
serde_json::Value::Null => None,
serde_json::Value::Bool(b) => Some((*b).into()),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Some(i.into())
} else if let Some(f) = n.as_f64() {
Some(f.into())
} else {
Some(n.to_string().into())
}
}
serde_json::Value::String(s) => Some(s.as_str().into()),
serde_json::Value::Array(arr) => {
let mut toml_arr = toml_edit::Array::new();
for elem in arr {
if let Some(v) = Self::json_to_toml_value(elem) {
toml_arr.push(v);
}
}
Some(toml_edit::Value::Array(toml_arr))
}
serde_json::Value::Object(obj) => {
let mut inline_table = toml_edit::InlineTable::new();
for (k, v) in obj {
if let Some(toml_v) = Self::json_to_toml_value(v) {
inline_table.insert(k, toml_v);
}
}
Some(toml_edit::Value::InlineTable(inline_table))
}
}
}
/// Converts a TOML [`Item`] back into a JSON-serialized string that
/// `serde_json::from_str` can parse.
fn toml_item_to_json_string(item: &Item) -> Option<String> {
match item {
Item::None => None,
Item::Value(v) => Self::toml_value_to_json(v),
Item::Table(t) => Some(Self::toml_table_to_json(t)),
Item::ArrayOfTables(arr) => {
let parts: Vec<String> = arr.iter().map(Self::toml_table_to_json).collect();
Some(format!("[{}]", parts.join(",")))
}
}
}
/// Converts a TOML [`Value`](toml_edit::Value) into a JSON string.
fn toml_value_to_json(v: &toml_edit::Value) -> Option<String> {
match v {
toml_edit::Value::Boolean(b) => {
Some(serde_json::to_string(b.value()).unwrap_or_default())
}
toml_edit::Value::Integer(i) => {
Some(serde_json::to_string(i.value()).unwrap_or_default())
}
toml_edit::Value::Float(f) => {
Some(serde_json::to_string(f.value()).unwrap_or_default())
}
toml_edit::Value::String(s) => {
// Always JSON-encode the string value.
Some(serde_json::to_string(s.value()).unwrap_or_default())
}
toml_edit::Value::Array(arr) => {
let parts: Vec<String> = arr.iter().filter_map(Self::toml_value_to_json).collect();
Some(format!("[{}]", parts.join(",")))
}
toml_edit::Value::InlineTable(t) => {
let parts: Vec<String> = t
.iter()
.filter_map(|(k, v)| {
Self::toml_value_to_json(v).map(|json_v| {
format!(
"{}:{}",
serde_json::to_string(k).unwrap_or_default(),
json_v
)
})
})
.collect();
Some(format!("{{{}}}", parts.join(",")))
}
_ => None,
}
}
/// Converts a TOML [`Table`] into a JSON object string.
fn toml_table_to_json(table: &Table) -> String {
let parts: Vec<String> = table
.iter()
.filter_map(|(k, item)| {
Self::toml_item_to_json_string(item).map(|json_v| {
format!(
"{}:{}",
serde_json::to_string(k).unwrap_or_default(),
json_v
)
})
})
.collect();
format!("{{{}}}", parts.join(","))
}
}
impl super::UserPreferences for TomlBackedUserPreferences {
fn is_settings_file(&self) -> bool {
true
}
fn reload_from_disk(&self) -> Result<(), super::Error> {
self.reload_from_disk()
}
fn write_value(&self, key: &str, val: String) -> Result<(), Error> {
self.write_value_with_hierarchy(key, val, None, None)
}
fn read_value(&self, key: &str) -> Result<Option<String>, Error> {
self.read_value_with_hierarchy(key, None)
}
fn remove_value(&self, key: &str) -> Result<(), Error> {
self.remove_value_with_hierarchy(key, None)
}
fn inhibit_writes_for_key(&self, key: &str, hierarchy: Option<&str>) {
let compound = Self::compound_key(key, hierarchy);
log::info!("Inhibiting writes for setting key {compound}");
self.write_inhibited_keys.borrow_mut().insert(compound);
}
fn clear_all_write_inhibitions(&self) {
self.write_inhibited_keys.borrow_mut().clear();
}
fn write_value_with_hierarchy(
&self,
key: &str,
val: String,
hierarchy: Option<&str>,
max_table_depth: Option<u32>,
) -> Result<(), Error> {
if self.is_key_write_inhibited(key, hierarchy) {
return Ok(());
}
let mut item = Self::json_value_to_toml_item(&val, max_table_depth);
// Apply pretty-printing before inserting. The assignment always
// lands at the top of a section table (either the root or the
// `[hierarchy]` table), so the value's own indent level is 0 and
// nested containers get +1 per level.
Self::prettify_item(&mut item, 0);
let mut doc = self.document.borrow_mut();
let table = match hierarchy {
Some(h) => Self::get_or_create_table(doc.as_table_mut(), h),
None => doc.as_table_mut(),
};
table[key] = item;
drop(doc);
self.flush()
}
fn read_value_with_hierarchy(
&self,
key: &str,
hierarchy: Option<&str>,
) -> Result<Option<String>, Error> {
let doc = self.document.borrow();
let table = match hierarchy {
Some(h) => match Self::get_table(doc.as_table(), h) {
Some(t) => t,
None => return Ok(None),
},
None => doc.as_table(),
};
match table.get(key) {
Some(item) => Ok(Self::toml_item_to_json_string(item)),
None => Ok(None),
}
}
fn remove_value_with_hierarchy(&self, key: &str, hierarchy: Option<&str>) -> Result<(), Error> {
if self.is_key_write_inhibited(key, hierarchy) {
return Ok(());
}
let mut doc = self.document.borrow_mut();
let table = match hierarchy {
Some(h) => {
// Navigate to the parent table; if it doesn't exist, nothing to remove.
let mut current = doc.as_table_mut();
for segment in h.split('.') {
if !current.contains_key(segment) || !current[segment].is_table() {
return Ok(());
}
current = current[segment].as_table_mut().ok_or_else(|| {
Error::Unknown(anyhow::anyhow!(
"expected table at segment '{segment}' in hierarchy '{h}'"
))
})?;
}
current
}
None => doc.as_table_mut(),
};
table.remove(key);
drop(doc);
self.flush()
}
}
#[cfg(test)]
#[path = "toml_backed_tests.rs"]
mod tests;
@@ -0,0 +1,745 @@
use super::*;
use toml_edit::Item;
#[test]
fn test_json_to_toml_round_trip_bool() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("true", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("true".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_integer() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("42", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("42".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_float() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("3.14", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("3.14".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_string() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("\"hello\"", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("\"hello\"".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_object() {
let input = r#"{"dark":"Phenomenon","light":"Paper"}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
// Objects should become TOML tables, not strings.
assert!(matches!(item, Item::Table(_)));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_null() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("null", None);
assert!(matches!(item, Item::None));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, None);
}
#[test]
fn test_json_to_toml_round_trip_array_of_strings() {
let input = r#"["cat","echo","ls"]"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Value(toml_edit::Value::Array(_))));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_array_of_objects() {
let input = r#"[{"AnchoredRegex":"^bash$"},{"AnchoredRegex":"^fish$"}]"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Value(toml_edit::Value::Array(_))));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_nested_struct() {
let input = r#"{"advanced_mode":false,"global":{"mode":"PreviousDir","custom_dir":""},"split_pane":{"mode":"HomeDir","custom_dir":"/tmp"}}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Table(_)));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_object_with_nulls() {
// Null fields should be omitted from the table, so the round-trip
// drops them. Verify that non-null fields survive.
let input = r#"{"keybinding":null,"active_pin_position":"Top","pin_screen":null,"hide_window_when_unfocused":true}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Table(_)));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
// Null fields are dropped.
let expected = serde_json::json!({
"active_pin_position": "Top",
"hide_window_when_unfocused": true,
});
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_empty_array() {
let input = "[]";
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Value(toml_edit::Value::Array(_))));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
assert_eq!(json, "[]");
}
#[test]
fn test_max_table_depth_zero_renders_inline() {
// depth 0 = entire value rendered inline
let input = r#"{"uniform_padding":0.0}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, Some(0));
// Should be an inline Value, not an Item::Table
assert!(matches!(
item,
Item::Value(toml_edit::Value::InlineTable(_))
));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_max_table_depth_one_renders_top_level_as_table_nested_as_inline() {
// depth 1 = top-level object is a section table, but nested objects are inline
let input = r#"{"active_pin_position":"Top","sizes":{"width":100,"height":50},"enabled":true}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, Some(1));
// Top level should be a table
let table = match &item {
Item::Table(t) => t,
other => panic!("expected Table, got {other:?}"),
};
// Primitive fields should be normal values
assert!(matches!(
table.get("active_pin_position"),
Some(Item::Value(_))
));
assert!(matches!(table.get("enabled"), Some(Item::Value(_))));
// Nested object should be an inline table value, not a sub-table
let sizes = table.get("sizes").expect("sizes should exist");
assert!(
matches!(sizes, Item::Value(toml_edit::Value::InlineTable(_))),
"nested object at depth 1 should be inline, got {sizes:?}"
);
// Round-trip the JSON
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_max_table_depth_none_renders_all_as_tables() {
// depth None = unlimited, nested objects become section tables
let input = r#"{"sizes":{"width":100,"height":50}}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
let table = match &item {
Item::Table(t) => t,
other => panic!("expected Table, got {other:?}"),
};
// Nested object should be a sub-table, not inline
assert!(
matches!(table.get("sizes"), Some(Item::Table(_))),
"nested object with None depth should be a section table"
);
}
#[test]
fn test_write_and_read_with_hierarchy() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
prefs
.write_value_with_hierarchy("font_size", "13.0".to_string(), Some("font"), None)
.unwrap();
prefs
.write_value_with_hierarchy(
"use_thin_strokes",
"true".to_string(),
Some("font.display"),
None,
)
.unwrap();
// Read back
let font_name = prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap();
assert_eq!(font_name, Some("\"Hack\"".to_string()));
let font_size = prefs
.read_value_with_hierarchy("font_size", Some("font"))
.unwrap();
assert_eq!(font_size, Some("13.0".to_string()));
let thin_strokes = prefs
.read_value_with_hierarchy("use_thin_strokes", Some("font.display"))
.unwrap();
assert_eq!(thin_strokes, Some("true".to_string()));
// Verify the TOML file structure
let contents = std::fs::read_to_string(&file_path).unwrap();
assert!(contents.contains("[font]"));
assert!(contents.contains("font_name"));
assert!(contents.contains("font_size"));
assert!(contents.contains("[font.display]"));
assert!(contents.contains("use_thin_strokes"));
}
#[test]
fn test_write_and_read_struct_value() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
// Write a struct value (JSON object) under a hierarchy.
let struct_json = r#"{"left_alt":false,"right_alt":true}"#;
prefs
.write_value_with_hierarchy(
"extra_meta_keys",
struct_json.to_string(),
Some("keys"),
None,
)
.unwrap();
// Read back — should produce equivalent JSON.
let read_back = prefs
.read_value_with_hierarchy("extra_meta_keys", Some("keys"))
.unwrap()
.unwrap();
let expected: serde_json::Value = serde_json::from_str(struct_json).unwrap();
let actual: serde_json::Value = serde_json::from_str(&read_back).unwrap();
assert_eq!(actual, expected);
// Verify the TOML file uses a sub-table, not a JSON string.
let contents = std::fs::read_to_string(&file_path).unwrap();
assert!(contents.contains("[keys.extra_meta_keys]"));
assert!(contents.contains("left_alt = false"));
assert!(contents.contains("right_alt = true"));
// Should NOT contain a JSON blob.
assert!(!contents.contains(r#"{"left_alt"#));
}
#[test]
fn test_new_with_invalid_toml_returns_error_and_recovers_on_reload() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("broken_settings.toml");
// Write invalid TOML to the file.
std::fs::write(&file_path, "this is [not valid toml =").unwrap();
// new() should succeed with an empty document and return the parse error.
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path.clone());
assert!(
parse_error.is_some(),
"expected a parse error for invalid TOML"
);
// The preferences should behave as empty — no values present.
assert_eq!(
prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap(),
None
);
// is_settings_file should still return true.
assert!(prefs.is_settings_file());
// Now fix the file with valid TOML.
std::fs::write(&file_path, "[font]\nfont_name = \"Hack\"\n").unwrap();
// reload_from_disk should succeed and pick up the new value.
assert!(prefs.reload_from_disk().is_ok());
assert_eq!(
prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap(),
Some("\"Hack\"".to_string())
);
}
#[test]
fn test_writes_inhibited_when_file_initially_broken() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("broken_settings.toml");
let original_content = "this is [not valid toml =";
std::fs::write(&file_path, original_content).unwrap();
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path.clone());
assert!(parse_error.is_some());
// Writing a setting should succeed in-memory but NOT overwrite the file.
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(
on_disk, original_content,
"broken file should not be overwritten by writes during inhibited state"
);
// Fix the file and reload.
std::fs::write(&file_path, "# now valid\n").unwrap();
assert!(prefs.reload_from_disk().is_ok());
// After reload, writes should flush to disk again.
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("font_name"),
"writes should flush to disk after successful reload"
);
}
#[test]
fn test_string_value_for_numeric_setting_reads_as_json_string() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
// Write a TOML file where font_size is a string instead of a number.
std::fs::write(&file_path, "[font]\nfont_size = \"not_a_number\"\n").unwrap();
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path);
assert!(
parse_error.is_none(),
"valid TOML should parse without error"
);
// Read the value — should return the JSON-encoded string.
let value = prefs
.read_value_with_hierarchy("font_size", Some("font"))
.unwrap();
// The TOML string "not_a_number" should be JSON-encoded as "\"not_a_number\""
assert_eq!(
value,
Some("\"not_a_number\"".to_string()),
"TOML string should be JSON-encoded"
);
// Attempting to deserialize as f32 should fail.
let result = serde_json::from_str::<f32>(value.as_deref().unwrap());
assert!(result.is_err(), "JSON string should not deserialize as f32");
}
#[test]
fn test_remove_with_hierarchy() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path);
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let val = prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap();
assert!(val.is_some());
prefs
.remove_value_with_hierarchy("font_name", Some("font"))
.unwrap();
let val = prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap();
assert!(val.is_none());
}
#[test]
fn test_per_key_write_inhibition_preserves_value_in_file() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
// Write a file with one valid and one "invalid" setting (the TOML itself
// is fine, but the caller decides the value is wrong for the type).
std::fs::write(
&file_path,
"[font]\nfont_size = \"abc\"\nfont_name = \"Hack\"\n",
)
.unwrap();
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path.clone());
assert!(parse_error.is_none(), "TOML itself is valid");
// Simulate the settings layer detecting the bad value.
prefs.inhibit_writes_for_key("font_size", Some("font"));
// Writing to the inhibited key should be a no-op.
prefs
.write_value_with_hierarchy("font_size", "13.0".to_string(), Some("font"), None)
.unwrap();
// Writing to the NON-inhibited key should succeed.
prefs
.write_value_with_hierarchy("font_name", "\"Fira Code\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
// The inhibited key's original value must still be present.
assert!(
on_disk.contains("font_size = \"abc\""),
"inhibited key's original value should be preserved, got: {on_disk}"
);
// The non-inhibited key's new value should be written.
assert!(
on_disk.contains("Fira Code"),
"non-inhibited key should be updated, got: {on_disk}"
);
}
#[test]
fn test_per_key_write_inhibition_blocks_remove() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
std::fs::write(&file_path, "[font]\nfont_size = \"abc\"\n").unwrap();
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs.inhibit_writes_for_key("font_size", Some("font"));
// Removing the inhibited key should be a no-op.
prefs
.remove_value_with_hierarchy("font_size", Some("font"))
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("font_size = \"abc\""),
"inhibited key should not be removed, got: {on_disk}"
);
}
#[test]
fn test_reload_clears_per_key_inhibitions() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
std::fs::write(&file_path, "[font]\nfont_size = \"abc\"\n").unwrap();
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs.inhibit_writes_for_key("font_size", Some("font"));
// Fix the file.
std::fs::write(&file_path, "[font]\nfont_size = 14.0\n").unwrap();
prefs.reload_from_disk().unwrap();
// After reload, the inhibition is cleared — writes should work.
prefs
.write_value_with_hierarchy("font_size", "16.0".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("16.0") || on_disk.contains("16"),
"write should succeed after reload cleared inhibition, got: {on_disk}"
);
}
#[test]
fn test_clear_all_write_inhibitions() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
std::fs::write(&file_path, "[font]\nfont_size = \"abc\"\nfont_name = 123\n").unwrap();
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs.inhibit_writes_for_key("font_size", Some("font"));
prefs.inhibit_writes_for_key("font_name", Some("font"));
prefs.clear_all_write_inhibitions();
// Both keys should now be writable.
prefs
.write_value_with_hierarchy("font_size", "14.0".to_string(), Some("font"), None)
.unwrap();
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("14") && on_disk.contains("Hack"),
"both keys should be writable after clearing all inhibitions, got: {on_disk}"
);
}
#[test]
fn test_file_content_hash_returns_none_for_missing_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("does_not_exist.toml");
assert_eq!(
None,
TomlBackedUserPreferences::file_content_hash(&file_path)
);
}
#[test]
fn test_file_content_hash_returns_none_for_empty_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("empty.toml");
std::fs::write(&file_path, "").unwrap();
assert_eq!(
None,
TomlBackedUserPreferences::file_content_hash(&file_path)
);
}
#[test]
fn test_file_content_hash_returns_none_for_whitespace_only_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("whitespace.toml");
std::fs::write(&file_path, " \n\t \n").unwrap();
assert_eq!(
None,
TomlBackedUserPreferences::file_content_hash(&file_path)
);
}
#[test]
fn test_file_content_hash_is_deterministic_for_identical_content() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("a.toml");
let path_b = dir.path().join("b.toml");
let contents = "[font]\nfont_size = 14.0\n";
std::fs::write(&path_a, contents).unwrap();
std::fs::write(&path_b, contents).unwrap();
let hash_a = TomlBackedUserPreferences::file_content_hash(&path_a);
let hash_b = TomlBackedUserPreferences::file_content_hash(&path_b);
assert!(hash_a.is_some());
assert_eq!(hash_a, hash_b);
}
#[test]
fn test_file_content_hash_differs_for_different_content() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("a.toml");
let path_b = dir.path().join("b.toml");
std::fs::write(&path_a, "[font]\nfont_size = 14.0\n").unwrap();
std::fs::write(&path_b, "[font]\nfont_size = 18.0\n").unwrap();
let hash_a = TomlBackedUserPreferences::file_content_hash(&path_a);
let hash_b = TomlBackedUserPreferences::file_content_hash(&path_b);
assert!(hash_a.is_some());
assert!(hash_b.is_some());
assert_ne!(hash_a, hash_b);
}
// Pretty-printing tests: verify that wide inline containers get broken
// across lines and that short ones stay on a single line. These drive the
// `prettify_item` pass in `toml_backed.rs`.
/// Writes `value_json` under the given hierarchy + key and returns the full
/// file contents. Also asserts the round-trip (read back as JSON matches).
fn write_and_read_file(
hierarchy: Option<&str>,
key: &str,
value_json: &str,
max_table_depth: Option<u32>,
) -> String {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs
.write_value_with_hierarchy(key, value_json.to_string(), hierarchy, max_table_depth)
.unwrap();
// Round-trip: reading back should yield the same JSON value.
let read_back = prefs
.read_value_with_hierarchy(key, hierarchy)
.unwrap()
.expect("value was just written");
let expected: serde_json::Value = serde_json::from_str(value_json).unwrap();
let actual: serde_json::Value = serde_json::from_str(&read_back).unwrap();
assert_eq!(actual, expected, "round-trip should preserve the value");
std::fs::read_to_string(&file_path).unwrap()
}
#[test]
fn test_pretty_print_wide_array_of_inline_tables() {
// Shaped like `custom_secret_regex_list`: each inline table has a
// short field count but the whole array is very wide.
let json = r#"[
{"name":"IPv4 Address","pattern":"\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"},
{"name":"OpenAI API Key","pattern":"\\bsk-[a-zA-Z0-9]{48}\\b"},
{"name":"GitHub Token","pattern":"\\bghp_[A-Za-z0-9_]{36}\\b"}
]"#;
let contents = write_and_read_file(Some("privacy"), "custom_secret_regex_list", json, None);
// Expect each inline table on its own indented line, with the closing
// bracket on a fresh line.
assert!(
contents.contains("custom_secret_regex_list = [\n"),
"array should open with a newline, got:\n{contents}"
);
assert!(
contents.contains("\n { name = \"IPv4 Address\""),
"each inline table should start on a new indented line, got:\n{contents}"
);
assert!(
contents.contains("\n]\n") || contents.ends_with("\n]"),
"closing bracket should be on its own line, got:\n{contents}"
);
// Each inline table itself is short (2 fields, fits well under
// MAX_INLINE_WIDTH), so it should still be on a single line.
assert!(
!contents.contains("{ name =\n") && !contents.contains("{\n name"),
"individual inline tables should stay on one line, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_short_primitive_array_stays_inline() {
let json = r#"["cat","echo","ls"]"#;
let contents = write_and_read_file(Some("terminal"), "allowed_commands", json, None);
// The whole thing fits on one line; it should stay inline.
assert!(
contents.contains("allowed_commands = [\"cat\", \"echo\", \"ls\"]"),
"short primitive array should stay inline, got:\n{contents}"
);
assert!(
!contents.contains("allowed_commands = [\n"),
"short primitive array should not be broken, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_long_primitive_array_goes_multiline() {
// Construct a primitive array whose single-line rendering clearly
// exceeds `MAX_INLINE_WIDTH`.
let items: Vec<String> = (0..20)
.map(|i| format!("\"/some/fairly/long/path/entry/number/{i}\""))
.collect();
let json = format!("[{}]", items.join(","));
let contents = write_and_read_file(Some("agents"), "allowlist", &json, None);
assert!(
contents.contains("allowlist = [\n"),
"long primitive array should open multi-line, got:\n{contents}"
);
// Each element should sit on its own indented line.
assert!(
contents.contains("\n \"/some/fairly/long/path/entry/number/0\","),
"first element should be on its own indented line with trailing comma, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_short_inline_table_stays_inline() {
// Force inline via max_table_depth = 0 and verify it stays on one line.
let json = r#"{"a":1,"b":2}"#;
let contents = write_and_read_file(Some("section"), "small", json, Some(0));
assert!(
contents.contains("small = { a = 1, b = 2 }"),
"short inline table should stay on one line, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_wide_inline_table_goes_multiline() {
// Force inline via max_table_depth = 0, with many long-valued fields so
// the single-line rendering is wider than `MAX_INLINE_WIDTH`.
let json = concat!(
"{",
"\"first\":\"a string value that is long enough\",",
"\"second\":\"another string value that is long enough\",",
"\"third\":\"one more string value that is long enough\"",
"}"
);
let contents = write_and_read_file(Some("section"), "big", json, Some(0));
assert!(
contents.contains("big = {\n"),
"wide inline table should open multi-line, got:\n{contents}"
);
// Each field should sit on its own indented line.
assert!(
contents.contains("\n first = "),
"first field should be on its own indented line, got:\n{contents}"
);
assert!(
contents.contains("\n second = "),
"second field should be on its own indented line, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_propagates_to_parent_array() {
// A short outer array (one element) containing a wide inline table.
// The inline table must go multi-line (width rule), and that forces
// the array multi-line too (propagation rule) — otherwise we'd render
// something ugly like `[{\n ...\n}]`.
let json = concat!(
"[{",
"\"first\":\"a string value that is long enough\",",
"\"second\":\"another string value that is long enough\",",
"\"third\":\"one more string value that is long enough\"",
"}]"
);
let contents = write_and_read_file(Some("section"), "items", json, None);
assert!(
contents.contains("items = [\n"),
"array should be multi-line because its child is multi-line, got:\n{contents}"
);
assert!(
contents.contains("{\n"),
"inline table should be multi-line, got:\n{contents}"
);
}
@@ -0,0 +1,100 @@
//! Implementation of the [`UserPreferences`] trait using macOS user defaults.
#![allow(deprecated)]
use cocoa::base::{id, nil};
use objc::{class, msg_send, rc::StrongPtr, sel, sel_impl};
/// A user preferences store backed by macOS user defaults (`NSUserDefaults`).
pub struct UserDefaultsPreferencesStorage {
/// A strong reference to the `NSUserDefaults` backing store.
user_defaults: StrongPtr,
}
impl UserDefaultsPreferencesStorage {
/// Constructs a new preferences store.
///
/// If `suite_name` is provided, it is used as the domain within
/// the user defaults system. Otherwise, the standard user defaults for
/// the current application are used.
pub fn new(suite_name: Option<String>) -> Self {
Self {
user_defaults: Self::user_defaults(suite_name),
}
}
/// Returns a strong reference to the `NSUserDefaults` backing store that
/// should be used for the given suite name.
///
/// If [`None`] is provided as the suite name, the standard user defaults
/// will be used (namespaced based on the current application).
fn user_defaults(suite_name: Option<String>) -> StrongPtr {
unsafe {
// Calling `[[NSUserDefaults alloc] initWithSuiteName]`` where the suite name is the
// application's bundle ID (the default `data_domain` if `data_profile` is unset)
// _should_ be equivalent to `[NSUserDefaults standardUserDefaults]`. However, in case
// the two ever deviate, we explicitly use `standardUserDefaults` below. The Apple docs
// also imply that `standardUserDefaults` is cached.
if let Some(suite_name) = &suite_name {
let defaults: id = msg_send![class!(NSUserDefaults), alloc];
let suite_name = util::make_nsstring(suite_name);
StrongPtr::new(msg_send![defaults, initWithSuiteName: *suite_name])
} else {
StrongPtr::retain(msg_send![class!(NSUserDefaults), standardUserDefaults])
}
}
}
}
impl super::UserPreferences for UserDefaultsPreferencesStorage {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
unsafe {
let key = util::make_nsstring(key);
let value = util::make_nsstring(&value);
let _: () = msg_send![*self.user_defaults, setObject: *value forKey: *key];
Ok(())
}
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
unsafe {
let key = util::make_nsstring(key);
let value: id = msg_send![*self.user_defaults, stringForKey: *key];
if value != nil {
Ok(Some(
galaxyui::platform::mac::utils::nsstring_as_str(value)?.to_owned(),
))
} else {
Ok(None)
}
}
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
unsafe {
let key = util::make_nsstring(key);
let _: () = msg_send![*self.user_defaults, removeObjectForKey: *key];
Ok(())
}
}
}
mod util {
use cocoa::{base::nil, foundation::NSString};
use objc::rc::StrongPtr;
/// Creates a new `NSString` from the given `&str`, wrapped in a
/// [`StrongPtr`] so it is released when the `StrongPtr` is dropped.
///
/// **Important:** when passing the result to `msg_send!`, always
/// dereference it (e.g. `msg_send![obj, foo: *nsstring]`). Passing the
/// `StrongPtr` itself by value causes it to be moved into the
/// `unsafe extern fn` call that `msg_send!` transmutes to, and Rust will
/// not run the `StrongPtr`'s `Drop` glue after that call, leaking the
/// underlying `NSString`.
pub fn make_nsstring(value: &str) -> StrongPtr {
unsafe { StrongPtr::new(NSString::alloc(nil).init_str(value)) }
}
}