Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -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)) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user