Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
#[macro_use]
|
||||
pub mod macros;
|
||||
pub mod manager;
|
||||
pub mod schema;
|
||||
|
||||
// Re-export commonly used types and traits
|
||||
pub use macros::SettingSection;
|
||||
pub use manager::SettingsManager;
|
||||
|
||||
// Re-export crates used by macro expansions in downstream crates.
|
||||
#[doc(hidden)]
|
||||
pub use inventory as _inventory;
|
||||
#[doc(hidden)]
|
||||
pub use schemars as _schemars;
|
||||
#[doc(hidden)]
|
||||
pub use settings_value as _settings_value;
|
||||
pub use settings_value::SettingsValue;
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Extracts the storage key (last segment after the final `.`) from a toml_path.
|
||||
///
|
||||
/// # Examples
|
||||
/// - `"appearance.text.font_name"` → `"font_name"`
|
||||
/// - `"font_name"` → `"font_name"`
|
||||
pub const fn toml_path_storage_key(path: &str) -> &str {
|
||||
let bytes = path.as_bytes();
|
||||
let mut i = path.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
if bytes[i] == b'.' {
|
||||
let (_, suffix) = path.split_at(i + 1);
|
||||
return suffix;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Extracts the hierarchy (everything before the final `.`) from a toml_path.
|
||||
///
|
||||
/// Returns `None` when the path contains no dot (the path is just a key).
|
||||
///
|
||||
/// # Examples
|
||||
/// - `"appearance.text.font_name"` → `Some("appearance.text")`
|
||||
/// - `"font_name"` → `None`
|
||||
pub const fn toml_path_hierarchy(path: &str) -> Option<&str> {
|
||||
let bytes = path.as_bytes();
|
||||
let mut i = path.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
if bytes[i] == b'.' {
|
||||
let (prefix, _) = path.split_at(i);
|
||||
return Some(prefix);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use warpui::{AppContext, Entity, ModelContext};
|
||||
use warpui_extras::user_preferences::UserPreferences;
|
||||
|
||||
/// Whether the TOML-backed settings file is active.
|
||||
///
|
||||
/// Set once during startup via [`set_settings_file_enabled`]. When `false`,
|
||||
/// public settings fall back to the private (platform-native) backend so
|
||||
/// that all settings share a single instance.
|
||||
static SETTINGS_FILE_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Records whether the TOML-backed settings file feature is active.
|
||||
///
|
||||
/// Call this once during startup after checking `FeatureFlag::SettingsFile`.
|
||||
/// The value is read by [`Setting::preferences_for_setting`] and
|
||||
/// [`SettingsManager::read_local_setting_value`] to decide which backend
|
||||
/// to use for public settings.
|
||||
pub fn set_settings_file_enabled(enabled: bool) {
|
||||
SETTINGS_FILE_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Returns whether the TOML-backed settings file is currently active.
|
||||
pub fn is_settings_file_enabled() -> bool {
|
||||
SETTINGS_FILE_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A newtype wrapper for the public preferences backend.
|
||||
///
|
||||
/// Public settings (those marked `private: false` in `define_settings_group!`)
|
||||
/// are stored in the user-visible settings file (TOML) when the `SettingsFile`
|
||||
/// feature flag is enabled, otherwise in the platform-native store.
|
||||
///
|
||||
/// The inner field is private and only accessible within the settings crate via
|
||||
/// [`as_preferences`](Self::as_preferences). This prevents external code from
|
||||
/// bypassing the settings macros to read/write public preferences directly.
|
||||
pub struct PublicPreferences(Box<dyn UserPreferences>);
|
||||
|
||||
impl PublicPreferences {
|
||||
pub fn new(prefs: Box<dyn UserPreferences>) -> Self {
|
||||
Self(prefs)
|
||||
}
|
||||
|
||||
/// Returns the underlying preferences backend.
|
||||
///
|
||||
/// This is intentionally `pub(crate)` so that only the settings
|
||||
/// infrastructure (macros, `Setting` trait, `SettingsManager`) can access
|
||||
/// the raw backend. External code must go through typed settings groups
|
||||
/// produced by `define_settings_group!`.
|
||||
pub(crate) fn as_preferences(&self) -> &dyn UserPreferences {
|
||||
self.0.as_ref()
|
||||
}
|
||||
|
||||
/// Returns whether this backend is the user-visible settings file.
|
||||
pub fn is_settings_file(&self) -> bool {
|
||||
self.0.is_settings_file()
|
||||
}
|
||||
|
||||
/// Reloads the backing store from disk.
|
||||
pub fn reload_from_disk(&self) -> Result<(), warpui_extras::user_preferences::Error> {
|
||||
self.0.reload_from_disk()
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for PublicPreferences {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl warpui::SingletonEntity for PublicPreferences {}
|
||||
|
||||
/// A newtype wrapper for the private preferences backend.
|
||||
///
|
||||
/// Private settings (those marked `private: true` in `define_settings_group!`)
|
||||
/// are stored here instead of in the user-visible settings file. This always
|
||||
/// uses the platform-native store (e.g. UserDefaults on macOS, JSON file on
|
||||
/// Linux, registry on Windows).
|
||||
pub struct PrivatePreferences(Box<dyn UserPreferences>);
|
||||
|
||||
impl PrivatePreferences {
|
||||
pub fn new(prefs: Box<dyn UserPreferences>) -> Self {
|
||||
Self(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for PrivatePreferences {
|
||||
type Target = dyn UserPreferences;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for PrivatePreferences {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl warpui::SingletonEntity for PrivatePreferences {}
|
||||
|
||||
/// An enum representing the different platforms a setting could apply to.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SupportedPlatforms {
|
||||
ALL,
|
||||
DESKTOP, /* Refers to running on device, not web-based, such as Mac, Linux, and Windows */
|
||||
MAC,
|
||||
LINUX,
|
||||
WINDOWS,
|
||||
WEB,
|
||||
OR(Box<SupportedPlatforms>, Box<SupportedPlatforms>),
|
||||
}
|
||||
|
||||
/// An enum representing the different ways a setting can be synced to the cloud.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncToCloud {
|
||||
/// The setting is synced to the cloud as a single global value that applies to on all supported platforms.
|
||||
Globally(RespectUserSyncSetting),
|
||||
|
||||
/// The setting is synced to the cloud as a value that is unique to each platform.
|
||||
PerPlatform(RespectUserSyncSetting),
|
||||
|
||||
/// The setting is not synced to the cloud.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Whether for this setting we respect the user toggle for settings sync.
|
||||
/// There are some cases we want to sync settings regardless of the user setting,
|
||||
/// such as for the value of whether cloud syncing is enabled, whether telemetry is enabled, etc.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RespectUserSyncSetting {
|
||||
/// Only sync if the user has settings sync enabled
|
||||
Yes,
|
||||
|
||||
/// Sync regardless of the user's setting
|
||||
No,
|
||||
}
|
||||
|
||||
impl SupportedPlatforms {
|
||||
pub fn matches_current_platform(&self) -> bool {
|
||||
match self {
|
||||
SupportedPlatforms::ALL => true,
|
||||
SupportedPlatforms::DESKTOP => {
|
||||
cfg!(not(target_family = "wasm"))
|
||||
}
|
||||
SupportedPlatforms::MAC => {
|
||||
cfg!(all(not(target_family = "wasm"), target_os = "macos"))
|
||||
}
|
||||
SupportedPlatforms::LINUX => {
|
||||
cfg!(all(not(target_family = "wasm"), target_os = "linux"))
|
||||
}
|
||||
SupportedPlatforms::WINDOWS => {
|
||||
cfg!(all(not(target_family = "wasm"), target_os = "windows"))
|
||||
}
|
||||
SupportedPlatforms::WEB => {
|
||||
cfg!(target_family = "wasm")
|
||||
}
|
||||
SupportedPlatforms::OR(first, second) => {
|
||||
first.matches_current_platform() || second.matches_current_platform()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An enum representing the reason for a change event.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ChangeEventReason {
|
||||
/// The change was initiated from a cloud sync
|
||||
CloudSync,
|
||||
|
||||
/// The change was initiated from a local setting change
|
||||
LocalChange,
|
||||
|
||||
/// The change was initiated from a clear operation
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// A representation of a setting which can be loaded from and persisted to some
|
||||
/// sort of durable storage.
|
||||
pub trait Setting {
|
||||
type Group: Entity;
|
||||
type Value: Serialize + DeserializeOwned + PartialEq + Debug + SettingsValue;
|
||||
|
||||
/// Constructs this setting object with the given initial value.
|
||||
/// If value is None, uses the default value and marks as not explicitly set.
|
||||
/// If value is Some, uses that value and marks as explicitly set.
|
||||
fn new(value: Option<Self::Value>) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Returns the name of the setting.
|
||||
fn setting_name() -> &'static str;
|
||||
|
||||
/// Returns the key underwhich this setting should be stored. Should be
|
||||
/// distinct from all other settings, and should not change over time.
|
||||
fn storage_key() -> &'static str;
|
||||
|
||||
/// Returns the full TOML path for this setting, if any.
|
||||
///
|
||||
/// The toml_path is a dot-separated path that includes both the hierarchy
|
||||
/// (section) and the storage key as the last segment. For example,
|
||||
/// `"appearance.text.font_name"` means the setting lives under
|
||||
/// `[appearance.text]` with key `font_name`.
|
||||
fn toml_path() -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the key used in the TOML settings file.
|
||||
///
|
||||
/// For settings with a `toml_path`, this is the last segment (e.g.
|
||||
/// `"font_name"` from `"appearance.text.font_name"`). For settings
|
||||
/// without a `toml_path`, falls back to `storage_key()`.
|
||||
fn toml_key() -> &'static str {
|
||||
Self::storage_key()
|
||||
}
|
||||
|
||||
/// Returns the hierarchy path for this setting, if any.
|
||||
///
|
||||
/// When set, hierarchy-aware preferences backends use this to organize
|
||||
/// settings into logical groups. For example, a hierarchy of `"font"`
|
||||
/// places the setting under a `font` section in the backing store.
|
||||
fn hierarchy() -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the maximum number of TOML section-table levels to use when
|
||||
/// rendering this setting's value in the settings file.
|
||||
///
|
||||
/// - `None` (default) — unlimited depth; nested objects become section
|
||||
/// tables (`[section.subsection]`) all the way down.
|
||||
/// - `Some(0)` — the value itself is rendered as an inline table
|
||||
/// (`key = { ... }`). Used for enum settings whose shape changes between
|
||||
/// variants.
|
||||
/// - `Some(1)` — the setting gets its own section header, but any nested
|
||||
/// objects within it are rendered inline. Useful for struct settings
|
||||
/// that contain maps or nested structs.
|
||||
fn max_table_depth() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the platforms that this setting is supported on.
|
||||
fn supported_platforms() -> SupportedPlatforms;
|
||||
|
||||
/// Returns whether and how this setting is synced to the cloud via Warp Drive.
|
||||
fn sync_to_cloud() -> SyncToCloud;
|
||||
|
||||
/// Returns whether this setting is private (not shown in the user-visible settings file).
|
||||
///
|
||||
/// Private settings are persisted to the platform-native store (e.g. UserDefaults on
|
||||
/// macOS) rather than the TOML settings file, ensuring they never appear in the
|
||||
/// user-editable file.
|
||||
fn is_private() -> bool;
|
||||
|
||||
/// Returns whether the current value of this setting should be synced.
|
||||
/// Only applies if sync_to_cloud() returns a value other than SyncToCloud::Never.
|
||||
/// Specific settings can implement this to filter which values should be synced.
|
||||
fn current_value_is_syncable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns whether the current value of this setting is syncable on the current platform,
|
||||
/// given the user's settings sync preference.
|
||||
fn is_setting_syncable_on_current_platform(&self, settings_sync_enabled: bool) -> bool {
|
||||
if !self.current_value_is_syncable() {
|
||||
return false;
|
||||
}
|
||||
match (Self::sync_to_cloud(), settings_sync_enabled) {
|
||||
(SyncToCloud::Never, _) => false,
|
||||
(SyncToCloud::Globally(RespectUserSyncSetting::No), _) => true,
|
||||
(SyncToCloud::Globally(RespectUserSyncSetting::Yes), true) => true,
|
||||
(SyncToCloud::Globally(RespectUserSyncSetting::Yes), false) => false,
|
||||
(SyncToCloud::PerPlatform(RespectUserSyncSetting::No), _) => {
|
||||
self.is_supported_on_current_platform()
|
||||
}
|
||||
(SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes), true) => {
|
||||
self.is_supported_on_current_platform()
|
||||
}
|
||||
(SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes), false) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current value of the setting. This may be different from
|
||||
/// the value persisted in storage.
|
||||
fn value(&self) -> &Self::Value;
|
||||
|
||||
/// Validates whether the new value is valid and returns a valid value to
|
||||
/// use. If the provided value is valid, the expectation is that this will
|
||||
/// return the provided value. If not, it is up to the implementation
|
||||
/// whether the current value of the setting is returned or whether some
|
||||
/// other value is returned.
|
||||
fn validate(&self, new_value: Self::Value) -> Self::Value {
|
||||
new_value
|
||||
}
|
||||
|
||||
/// Clears the value of the setting from persistent storage and fires a change event
|
||||
/// indicating that the value was cleared.
|
||||
fn clear_value(&mut self, ctx: &mut ModelContext<Self::Group>) -> anyhow::Result<()>;
|
||||
|
||||
/// Loads a value into memory without persisting it to storage.
|
||||
///
|
||||
/// Used during hot-reload to sync in-memory state with the file on disk.
|
||||
/// Unlike [`set_value`](Self::set_value), this never writes to the
|
||||
/// preferences backend, avoiding write-back loops with the file watcher.
|
||||
fn load_value(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
explicitly_set: bool,
|
||||
ctx: &mut ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Sets the value of the setting persisting it to storage. The change event indicates
|
||||
/// that the update was initiated from a cloud sync.
|
||||
fn set_value_from_cloud_sync(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Sets the value of the setting persisting it to storage.
|
||||
fn set_value(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
ctx: &mut ModelContext<Self::Group>,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Returns the default value of the setting.
|
||||
fn default_value() -> Self::Value;
|
||||
|
||||
/// Sets the value of the setting to its default and persists it to storage.
|
||||
fn set_value_to_default(
|
||||
&mut self,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
self.set_value(Self::default_value(), ctx)
|
||||
}
|
||||
|
||||
/// Returns the appropriate preferences backend for this setting.
|
||||
///
|
||||
/// Private settings use the platform-native store; public settings use
|
||||
/// the main preferences backend (which may be the TOML settings file).
|
||||
fn preferences_for_setting(ctx: &AppContext) -> &dyn UserPreferences {
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
if Self::is_private() {
|
||||
<PrivatePreferences as SingletonEntity>::as_ref(ctx).deref()
|
||||
} else if is_settings_file_enabled() {
|
||||
<PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences()
|
||||
} else {
|
||||
// When the settings file is disabled, fall back to the private
|
||||
// backend so both paths share a single instance.
|
||||
<PrivatePreferences as SingletonEntity>::as_ref(ctx).deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new instance of the setting, populating its initial value
|
||||
/// based on any previously-stored value, falling back to
|
||||
/// `Self::default_value()` if no value was stored or it could not be parsed
|
||||
/// successfully.
|
||||
fn new_from_storage(ctx: &mut AppContext) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Self::new(Self::read_from_preferences(Self::preferences_for_setting(
|
||||
ctx,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Reads the setting's value from the provided preferences, returning None
|
||||
/// if the value is not set or could not be parsed successfully.
|
||||
fn read_from_preferences(preferences: &dyn UserPreferences) -> Option<Self::Value> {
|
||||
let key = if preferences.is_settings_file() {
|
||||
Self::toml_key()
|
||||
} else {
|
||||
Self::storage_key()
|
||||
};
|
||||
let value = preferences
|
||||
.read_value_with_hierarchy(key, Self::hierarchy())
|
||||
.unwrap_or_default()?;
|
||||
|
||||
// For the settings file, use the SettingsValue trait.
|
||||
if preferences.is_settings_file() {
|
||||
let json_value = match serde_json::from_str::<serde_json::Value>(&value) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to parse JSON for setting {}: {err:?}",
|
||||
Self::storage_key()
|
||||
);
|
||||
preferences.inhibit_writes_for_key(Self::toml_key(), Self::hierarchy());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match <Self::Value as SettingsValue>::from_file_value(&json_value) {
|
||||
Some(val) => {
|
||||
log::debug!(
|
||||
"Loaded {} from settings file; value: {:?}",
|
||||
Self::setting_name(),
|
||||
val
|
||||
);
|
||||
return Some(val);
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"Failed to parse file value for setting {}",
|
||||
Self::storage_key()
|
||||
);
|
||||
preferences.inhibit_writes_for_key(Self::toml_key(), Self::hierarchy());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::from_str(&value) {
|
||||
Ok(val) => {
|
||||
log::debug!(
|
||||
"Loaded {} from user defaults; value: {:?}",
|
||||
Self::setting_name(),
|
||||
val
|
||||
);
|
||||
Some(val)
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Failed to parse stored value for setting {}: {err:?}",
|
||||
Self::storage_key()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the current value of the setting in some form of durable
|
||||
/// storage. Returns whether the value was changed from what was currently
|
||||
/// stored.
|
||||
fn write_to_preferences(
|
||||
new_value: &Self::Value,
|
||||
preferences: &dyn UserPreferences,
|
||||
) -> Result<bool> {
|
||||
let key = if preferences.is_settings_file() {
|
||||
Self::toml_key()
|
||||
} else {
|
||||
Self::storage_key()
|
||||
};
|
||||
|
||||
// For the settings file, use the SettingsValue trait.
|
||||
let value = if preferences.is_settings_file() {
|
||||
let file_value = <Self::Value as SettingsValue>::to_file_value(new_value);
|
||||
serde_json::to_string(&file_value).context(format!(
|
||||
"Failed to write {} to storage",
|
||||
Self::storage_key()
|
||||
))?
|
||||
} else {
|
||||
serde_json::to_string(new_value).context(format!(
|
||||
"Failed to write {} to storage",
|
||||
Self::storage_key()
|
||||
))?
|
||||
};
|
||||
|
||||
// Compare semantically by deserializing the stored value back into
|
||||
// the typed value rather than comparing JSON strings. This avoids
|
||||
// spurious writes caused by serialization differences (key ordering,
|
||||
// null-vs-missing fields, formatting) that don't represent actual
|
||||
// value changes.
|
||||
let stored_value_matches = preferences
|
||||
.read_value_with_hierarchy(key, Self::hierarchy())?
|
||||
.as_deref()
|
||||
.and_then(|stored| {
|
||||
if preferences.is_settings_file() {
|
||||
let json_value = serde_json::from_str::<serde_json::Value>(stored).ok()?;
|
||||
return <Self::Value as SettingsValue>::from_file_value(&json_value);
|
||||
}
|
||||
serde_json::from_str::<Self::Value>(stored).ok()
|
||||
})
|
||||
.is_some_and(|stored_val| &stored_val == new_value);
|
||||
|
||||
if !stored_value_matches {
|
||||
log::debug!(
|
||||
"Writing new value of {} to storage; key: {}; value: {:?}",
|
||||
Self::setting_name(),
|
||||
key,
|
||||
value
|
||||
);
|
||||
let _ = preferences.write_value_with_hierarchy(
|
||||
key,
|
||||
value,
|
||||
Self::hierarchy(),
|
||||
Self::max_table_depth(),
|
||||
);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the setting from the given durable storage and returns whether the setting was cleared.
|
||||
fn clear_from_preferences(preferences: &dyn UserPreferences) -> Result<()> {
|
||||
let key = if preferences.is_settings_file() {
|
||||
Self::toml_key()
|
||||
} else {
|
||||
Self::storage_key()
|
||||
};
|
||||
log::debug!(
|
||||
"Clearing setting {} with key {} from preferences",
|
||||
Self::setting_name(),
|
||||
key,
|
||||
);
|
||||
preferences.remove_value_with_hierarchy(key, Self::hierarchy())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if this setting is supported on the current platform (e.g., Web, Linux, Mac). For example,
|
||||
/// Background opacity is supported on Mac and Linux, not Web.
|
||||
fn is_supported_on_current_platform(&self) -> bool;
|
||||
|
||||
/// Returns true if this setting was explicitly set by the user (i.e., not using the default value).
|
||||
fn is_value_explicitly_set(&self) -> bool;
|
||||
}
|
||||
|
||||
/// A trait for settings that can be toggled between two values.
|
||||
pub trait ToggleableSetting: Setting {
|
||||
/// Toggles the value of the setting and persists it to storage, returning
|
||||
/// the new value upon success.
|
||||
fn toggle_and_save_value(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<<Self as Setting>::Group>,
|
||||
) -> Result<<Self as Setting>::Value>;
|
||||
}
|
||||
|
||||
impl<T, S> ToggleableSetting for S
|
||||
where
|
||||
T: std::ops::Not<Output = T> + Copy + Debug,
|
||||
S: Setting<Value = T>,
|
||||
{
|
||||
fn toggle_and_save_value(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<<Self as Setting>::Group>,
|
||||
) -> Result<<Self as Setting>::Value> {
|
||||
let current_value = *self.value();
|
||||
let new_value = !current_value;
|
||||
log::debug!(
|
||||
"Toggling value of {} from {:?} to {:?}",
|
||||
Self::setting_name(),
|
||||
current_value,
|
||||
new_value
|
||||
);
|
||||
self.set_value(new_value, ctx)?;
|
||||
Ok(new_value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "toml_path_tests.rs"]
|
||||
mod toml_path_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod mod_tests;
|
||||
@@ -0,0 +1,955 @@
|
||||
//! This module defines a set of macros to standardize and simplify the process
|
||||
//! of defining new settings within Warp.
|
||||
//!
|
||||
//! Settings are defined as enums or structs that implement [`Setting`], and are
|
||||
//! organized into groups in singleton models which contain one or more settings
|
||||
//! and automatically emit an event notifying interested listeners when a
|
||||
//! setting changes (specifying which setting was updated). A setting can hold
|
||||
//! any type which has a default value, supports equality checks, and can be
|
||||
//! both serialized and deserialized.
|
||||
//!
|
||||
//! # Defining settings
|
||||
//!
|
||||
//! ## Defining a new setting group
|
||||
//!
|
||||
//! This shows the simplest usage of these macros - creating a group of settings
|
||||
//! where each setting has its implementation automatically generated for you.
|
||||
//!
|
||||
//! ```
|
||||
//! # use settings::*;
|
||||
//! # use settings::macros::*;
|
||||
//! define_settings_group!(ExampleGroup, settings: [
|
||||
//! bool_setting: BoolSetting {
|
||||
//! type: bool,
|
||||
//! default: false,
|
||||
//! supported_platforms: SupportedPlatforms::ALL,
|
||||
//! sync_to_cloud: SyncToCloud::Never,
|
||||
//! private: false,
|
||||
//! toml_path: "example.bool_setting",
|
||||
//! },
|
||||
//! float_setting: FloatSetting {
|
||||
//! type: f32,
|
||||
//! default: 3.14,
|
||||
//! supported_platforms: SupportedPlatforms::ALL,
|
||||
//! sync_to_cloud: SyncToCloud::Never,
|
||||
//! private: false,
|
||||
//! toml_path: "example.float_setting",
|
||||
//! },
|
||||
//! ]);
|
||||
//! ```
|
||||
//!
|
||||
//! The macro also generates an `Event` type that is passed to subscribers of the
|
||||
//! setting group model. The name of the type is created by appended 'ChangedEvent'
|
||||
//! to the name of the setting group:
|
||||
//!
|
||||
//! ```
|
||||
//! pub enum ExampleGroupChangedEvent {
|
||||
//! BoolSetting,
|
||||
//! FloatSetting
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Note that this event type must be explicitly included in `use` statements to
|
||||
//! bring it into scope.
|
||||
//!
|
||||
//! # Turning an existing enum into a setting
|
||||
//!
|
||||
//! You can also "upgrade" an existing enum into a setting. As with
|
||||
//! primitive-based settings, you'll need to make sure your enum implements
|
||||
//! [`Default`], [`PartialEq`], [`serde::Serialize`], and
|
||||
//! [`serde::Deserialize`].
|
||||
//!
|
||||
//! ```
|
||||
//! # use schemars::JsonSchema;
|
||||
//! # use serde::{Deserialize, Serialize};
|
||||
//! # use settings::macros::*;
|
||||
//! # use settings::*;
|
||||
//! #[derive(Default, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
//! enum MyEnum {
|
||||
//! #[default]
|
||||
//! Unit,
|
||||
//! Tuple(bool),
|
||||
//! Struct { inner: f32 },
|
||||
//! }
|
||||
//!
|
||||
//! impl settings_value::SettingsValue for MyEnum {}
|
||||
//!
|
||||
//! implement_setting_for_enum!(MyEnum, EnumSettingsGroup, SupportedPlatforms::ALL, SyncToCloud::Never, private: false, toml_path: "example.my_enum");
|
||||
//!
|
||||
//! define_settings_group!(EnumSettingsGroup, settings: [
|
||||
//! my_enum: MyEnum,
|
||||
//! ]);
|
||||
//! ```
|
||||
//!
|
||||
//! ## Syncing a setting to the cloud.
|
||||
//!
|
||||
//! It's easy to declare a setting as being synced to the cloud by
|
||||
//! setting the sync_to_cloud field to either Global or PerPlatform.
|
||||
//! For either syncing option you can specify whether the setting
|
||||
//! should be synced regardless of the current state of
|
||||
//! CloudPreferencesSettings.
|
||||
//!
|
||||
//! ```
|
||||
//! # use settings::macros::*;
|
||||
//! # use settings::*;
|
||||
//! define_settings_group!(OverrideSettingsGroup, settings: [
|
||||
//! to_override: ToOverride {
|
||||
//! type: bool,
|
||||
//! default: false,
|
||||
//! supported_platforms: SupportedPlatforms::ALL,
|
||||
//! sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
//! private: false,
|
||||
//! toml_path: "example.to_override",
|
||||
//! },
|
||||
//! ]);
|
||||
//! ```
|
||||
//!
|
||||
//! # Using settings
|
||||
//!
|
||||
//! Once you've defined a setting, usage is straightforward:
|
||||
//!
|
||||
//! ```
|
||||
//! # use warpui::*;
|
||||
//! # use settings::macros::*;
|
||||
//! # use settings::manager::SettingsManager;
|
||||
//! # use settings::*;
|
||||
//! # use warpui_extras::user_preferences;
|
||||
//! define_settings_group!(ExampleGroup, settings: [
|
||||
//! bool_setting: BoolSetting {
|
||||
//! type: bool,
|
||||
//! default: false,
|
||||
//! supported_platforms: SupportedPlatforms::ALL,
|
||||
//! sync_to_cloud: SyncToCloud::Never,
|
||||
//! private: false,
|
||||
//! toml_path: "example.bool_setting",
|
||||
//! },
|
||||
//! ]);
|
||||
//!
|
||||
//! App::test((), |mut app| async move {
|
||||
//! // Initialize the underlying user preferences system.
|
||||
//! app.add_singleton_model(move |_ctx| {
|
||||
//! PublicPreferences::new(Box::<user_preferences::in_memory::InMemoryPreferences>::default())
|
||||
//! });
|
||||
//!
|
||||
//! app.add_singleton_model(move |_ctx| {
|
||||
//! PrivatePreferences::new(Box::<user_preferences::in_memory::InMemoryPreferences>::default())
|
||||
//! });
|
||||
//!
|
||||
//! app.add_singleton_model(|_ctx| SettingsManager::default());
|
||||
//!
|
||||
//! // Register the settings group singleton model with the application.
|
||||
//! ExampleGroup::register(&mut app);
|
||||
//!
|
||||
//! // Read the value:
|
||||
//! app.read(|ctx| {
|
||||
//! let value = ExampleGroup::handle(ctx)
|
||||
//! .as_ref(ctx)
|
||||
//! .bool_setting
|
||||
//! .value();
|
||||
//! });
|
||||
//!
|
||||
//! // Update the value:
|
||||
//! app.update(|ctx| {
|
||||
//! let _ = ExampleGroup::handle(ctx)
|
||||
//! .update(ctx, |example_group, ctx| {
|
||||
//! example_group.bool_setting.set_value(true, ctx);
|
||||
//! });
|
||||
//! });
|
||||
//! });
|
||||
//!
|
||||
//! // Subscribe to the value changes from a view:
|
||||
//! impl MyView {
|
||||
//! pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
//! let handle = ExampleGroup::handle(ctx);
|
||||
//! ctx.subscribe_to_model(&handle, |me, _handle, event, _ctx| {
|
||||
//! match event {
|
||||
//! ExampleGroupChangedEvent::BoolSetting { .. } => {
|
||||
//! me.handle_changed_bool_setting();
|
||||
//! }
|
||||
//! }
|
||||
//! });
|
||||
//!
|
||||
//! Self {}
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! struct MyView {}
|
||||
//!
|
||||
//! impl Entity for MyView {
|
||||
//! type Event = ();
|
||||
//! }
|
||||
//!
|
||||
//! impl View for MyView {
|
||||
//! fn ui_name() -> &'static str {
|
||||
//! "MyView"
|
||||
//! }
|
||||
//!
|
||||
//! fn render(&self, app_ctx: &AppContext) -> Box<dyn Element> {
|
||||
//! elements::Rect::new().finish()
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! impl MyView {
|
||||
//! fn handle_changed_bool_setting(&self) {
|
||||
//! println!("Bool setting changed.");
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub use ::concat_idents::concat_idents;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! define_setting {
|
||||
// Convenience arm: with storage_key + toml_path + max_table_depth
|
||||
($name:ident: $type:ty, default: $default:tt, supported_platforms: $supported_platforms: expr, group: $group:path, storage_key: $storage_key:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr, max_table_depth: $mtd:literal $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
$crate::macros::define_setting!(@base $name: $type, default: $default, supported_platforms: $supported_platforms, group: $group, sync_to_cloud: $sync_to_cloud, private: $private, storage_key: $storage_key, toml_path_value: Some($toml_path), max_table_depth_value: $mtd $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// Convenience arm: with toml_path + max_table_depth (no explicit storage_key)
|
||||
($name:ident: $type:ty, default: $default:tt, supported_platforms: $supported_platforms: expr, group: $group:path, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr, max_table_depth: $mtd:literal $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
$crate::macros::define_setting!(@base $name: $type, default: $default, supported_platforms: $supported_platforms, group: $group, sync_to_cloud: $sync_to_cloud, private: $private, storage_key: stringify!($name), toml_path_value: Some($toml_path), max_table_depth_value: $mtd $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// Convenience arm: with storage_key + toml_path
|
||||
($name:ident: $type:ty, default: $default:tt, supported_platforms: $supported_platforms: expr, group: $group:path, storage_key: $storage_key:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
$crate::macros::define_setting!(@base $name: $type, default: $default, supported_platforms: $supported_platforms, group: $group, sync_to_cloud: $sync_to_cloud, private: $private, storage_key: $storage_key, toml_path_value: Some($toml_path) $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// Convenience arm: with toml_path (no explicit storage_key)
|
||||
($name:ident: $type:ty, default: $default:tt, supported_platforms: $supported_platforms: expr, group: $group:path, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
$crate::macros::define_setting!(@base $name: $type, default: $default, supported_platforms: $supported_platforms, group: $group, sync_to_cloud: $sync_to_cloud, private: $private, storage_key: stringify!($name), toml_path_value: Some($toml_path) $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// Convenience arm: without toml_path (private settings with explicit storage_key)
|
||||
($name:ident: $type:ty, default: $default:tt, supported_platforms: $supported_platforms: expr, group: $group:path, storage_key: $storage_key:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
$crate::macros::define_setting!(@base $name: $type, default: $default, supported_platforms: $supported_platforms, group: $group, sync_to_cloud: $sync_to_cloud, private: $private, storage_key: $storage_key, toml_path_value: None::<&str> $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// Convenience arm: without toml_path (private settings with default storage_key)
|
||||
($name:ident: $type:ty, default: $default:tt, supported_platforms: $supported_platforms: expr, group: $group:path, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
$crate::macros::define_setting!(@base $name: $type, default: $default, supported_platforms: $supported_platforms, group: $group, sync_to_cloud: $sync_to_cloud, private: $private, storage_key: stringify!($name), toml_path_value: None::<&str> $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// Base arm: generates the struct and Setting impl
|
||||
(@base $name:ident: $type:ty, default: $default:tt, supported_platforms: $supported_platforms: expr, group: $group:path, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, storage_key: $storage_key:expr, toml_path_value: $toml_path_value:expr $(, max_table_depth_value: $mtd:literal)? $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
pub struct $name {
|
||||
inner: $type,
|
||||
is_explicitly_set: bool,
|
||||
}
|
||||
|
||||
const _: () = {
|
||||
let toml_path: Option<&str> = $toml_path_value;
|
||||
if !$private && toml_path.is_none() {
|
||||
panic!("non-private settings must specify a toml_path");
|
||||
}
|
||||
};
|
||||
|
||||
impl $crate::Setting for $name {
|
||||
type Value = $type;
|
||||
type Group = $group;
|
||||
|
||||
/// Creates a new setting with the given value, if provided, otherwise
|
||||
/// uses the default value. Also tracks whether the setting was explicitly
|
||||
/// set or not.
|
||||
fn new(value: Option<Self::Value>) -> Self {
|
||||
match value {
|
||||
Some(v) => Self {
|
||||
inner: v,
|
||||
is_explicitly_set: true,
|
||||
},
|
||||
None => {
|
||||
let default_value = Self::default_value();
|
||||
log::debug!(
|
||||
"Initializing {} to default value: {:?}",
|
||||
Self::setting_name(),
|
||||
default_value
|
||||
);
|
||||
Self {
|
||||
inner: default_value,
|
||||
is_explicitly_set: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setting_name() -> &'static str {
|
||||
stringify!($name)
|
||||
}
|
||||
|
||||
fn toml_path() -> Option<&'static str> {
|
||||
$toml_path_value
|
||||
}
|
||||
|
||||
fn storage_key() -> &'static str {
|
||||
$storage_key
|
||||
}
|
||||
|
||||
fn toml_key() -> &'static str {
|
||||
const KEY: &str = match $toml_path_value {
|
||||
Some(path) => $crate::toml_path_storage_key(path),
|
||||
None => $storage_key,
|
||||
};
|
||||
KEY
|
||||
}
|
||||
|
||||
fn hierarchy() -> Option<&'static str> {
|
||||
const HIER: Option<&str> = match $toml_path_value {
|
||||
Some(path) => $crate::toml_path_hierarchy(path),
|
||||
None => None,
|
||||
};
|
||||
HIER
|
||||
}
|
||||
|
||||
fn sync_to_cloud() -> $crate::SyncToCloud {
|
||||
$sync_to_cloud
|
||||
}
|
||||
|
||||
fn is_private() -> bool {
|
||||
$private
|
||||
}
|
||||
|
||||
fn supported_platforms() -> SupportedPlatforms {
|
||||
$supported_platforms
|
||||
}
|
||||
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
fn clear_value(
|
||||
&mut self,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
Self::clear_from_preferences(Self::preferences_for_setting(ctx))?;
|
||||
self.inner = self.validate(Self::default_value());
|
||||
self.is_explicitly_set = false;
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::Clear,
|
||||
}}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value_from_cloud_sync(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
let changed_in_storage =
|
||||
Self::write_to_preferences(&new_value, Self::preferences_for_setting(ctx))?;
|
||||
if self.value() != &new_value || changed_in_storage {
|
||||
self.inner = self.validate(new_value);
|
||||
self.is_explicitly_set = true;
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::CloudSync,
|
||||
}}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
let changed_in_storage =
|
||||
Self::write_to_preferences(&new_value, Self::preferences_for_setting(ctx))?;
|
||||
if self.value() != &new_value || changed_in_storage {
|
||||
self.inner = self.validate(new_value);
|
||||
self.is_explicitly_set = true;
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::LocalChange,
|
||||
}}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_value(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
explicitly_set: bool,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
let validated = self.validate(new_value);
|
||||
if self.value() != &validated || self.is_explicitly_set != explicitly_set {
|
||||
self.inner = validated;
|
||||
self.is_explicitly_set = explicitly_set;
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::LocalChange,
|
||||
}}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_value() -> Self::Value {
|
||||
$default
|
||||
}
|
||||
|
||||
fn is_supported_on_current_platform(&self) -> bool {
|
||||
$supported_platforms.matches_current_platform()
|
||||
}
|
||||
|
||||
fn is_value_explicitly_set(&self) -> bool {
|
||||
self.is_explicitly_set
|
||||
}
|
||||
|
||||
$(
|
||||
fn max_table_depth() -> Option<u32> {
|
||||
Some($mtd)
|
||||
}
|
||||
)?
|
||||
}
|
||||
|
||||
impl std::ops::Deref for $name {
|
||||
type Target = $type;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
use $crate::Setting;
|
||||
self.value()
|
||||
}
|
||||
}
|
||||
|
||||
$crate::submit_schema_entry!(
|
||||
private: $private,
|
||||
description: $crate::_schema_default_description!($($desc)?),
|
||||
toml_path_value: $toml_path_value,
|
||||
fallback_storage_key: $storage_key,
|
||||
supported_platforms: $supported_platforms,
|
||||
feature_flag: $crate::_schema_default_flag!($($flag)?),
|
||||
max_table_depth: $crate::_schema_default_max_table_depth!($($mtd)?),
|
||||
default: $default,
|
||||
value_type: $type
|
||||
);
|
||||
};
|
||||
}
|
||||
pub use define_setting;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! maybe_define_setting {
|
||||
// storage_key + toml_path + max_table_depth
|
||||
($setting:ident, group: $group:path, { type: $value_type:ty, default: $default:expr, supported_platforms: $supported_platforms:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, storage_key: $key:expr, toml_path: $toml_path:expr, max_table_depth: $mtd:literal $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)? }) => {
|
||||
$crate::macros::define_setting!(
|
||||
$setting: $value_type,
|
||||
default: $default,
|
||||
supported_platforms: $supported_platforms,
|
||||
group: $group,
|
||||
storage_key: $key,
|
||||
sync_to_cloud: $sync_to_cloud,
|
||||
private: $private,
|
||||
toml_path: $toml_path,
|
||||
max_table_depth: $mtd
|
||||
$(, description: $desc)?
|
||||
$(, feature_flag: $flag)?
|
||||
);
|
||||
};
|
||||
// toml_path + max_table_depth (no explicit storage_key)
|
||||
($setting:ident, group: $group:path, { type: $value_type:ty, default: $default:expr, supported_platforms: $supported_platforms:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr, max_table_depth: $mtd:literal $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)? }) => {
|
||||
$crate::macros::define_setting!(
|
||||
$setting: $value_type,
|
||||
default: $default,
|
||||
supported_platforms: $supported_platforms,
|
||||
group: $group,
|
||||
sync_to_cloud: $sync_to_cloud,
|
||||
private: $private,
|
||||
toml_path: $toml_path,
|
||||
max_table_depth: $mtd
|
||||
$(, description: $desc)?
|
||||
$(, feature_flag: $flag)?
|
||||
);
|
||||
};
|
||||
// storage_key + toml_path
|
||||
($setting:ident, group: $group:path, { type: $value_type:ty, default: $default:expr, supported_platforms: $supported_platforms:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, storage_key: $key:expr, toml_path: $toml_path:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)? }) => {
|
||||
$crate::macros::define_setting!(
|
||||
$setting: $value_type,
|
||||
default: $default,
|
||||
supported_platforms: $supported_platforms,
|
||||
group: $group,
|
||||
storage_key: $key,
|
||||
sync_to_cloud: $sync_to_cloud,
|
||||
private: $private,
|
||||
toml_path: $toml_path
|
||||
$(, description: $desc)?
|
||||
$(, feature_flag: $flag)?
|
||||
);
|
||||
};
|
||||
// toml_path only (no explicit storage_key)
|
||||
($setting:ident, group: $group:path, { type: $value_type:ty, default: $default:expr, supported_platforms: $supported_platforms:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)? }) => {
|
||||
$crate::macros::define_setting!(
|
||||
$setting: $value_type,
|
||||
default: $default,
|
||||
supported_platforms: $supported_platforms,
|
||||
group: $group,
|
||||
sync_to_cloud: $sync_to_cloud,
|
||||
private: $private,
|
||||
toml_path: $toml_path
|
||||
$(, description: $desc)?
|
||||
$(, feature_flag: $flag)?
|
||||
);
|
||||
};
|
||||
// storage_key only, no toml_path (private settings with custom key)
|
||||
($setting:ident, group: $group:path, { type: $value_type:ty, default: $default:expr, supported_platforms: $supported_platforms:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr, storage_key: $key:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)? }) => {
|
||||
$crate::macros::define_setting!(
|
||||
$setting: $value_type,
|
||||
default: $default,
|
||||
supported_platforms: $supported_platforms,
|
||||
group: $group,
|
||||
storage_key: $key,
|
||||
sync_to_cloud: $sync_to_cloud,
|
||||
private: $private
|
||||
$(, description: $desc)?
|
||||
$(, feature_flag: $flag)?
|
||||
);
|
||||
};
|
||||
// neither toml_path nor storage_key (private settings with default key)
|
||||
($setting:ident, group: $group:path, { type: $value_type:ty, default: $default:expr, supported_platforms: $supported_platforms:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)? }) => {
|
||||
$crate::macros::define_setting!(
|
||||
$setting: $value_type,
|
||||
default: $default,
|
||||
supported_platforms: $supported_platforms,
|
||||
group: $group,
|
||||
sync_to_cloud: $sync_to_cloud,
|
||||
private: $private
|
||||
$(, description: $desc)?
|
||||
$(, feature_flag: $flag)?
|
||||
);
|
||||
};
|
||||
($setting:ident, group: $group:path) => {};
|
||||
}
|
||||
pub use maybe_define_setting;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! implement_setting_for_enum {
|
||||
// Base arm with all parameters
|
||||
(@base $name:ident, $group:path, $supported_platforms:expr, $sync_to_cloud:expr, private: $private:expr, storage_key: $storage_key:expr, toml_path_value: $toml_path_value:expr $(, max_table_depth_value: $mtd:literal)? $(, description: $desc:literal)? $(, feature_flag: $flag:path)?) => {
|
||||
const _: () = {
|
||||
let toml_path: Option<&str> = $toml_path_value;
|
||||
if !$private && toml_path.is_none() {
|
||||
panic!("non-private settings must specify a toml_path");
|
||||
}
|
||||
};
|
||||
|
||||
impl $crate::Setting for $name {
|
||||
type Value = $name;
|
||||
type Group = $group;
|
||||
|
||||
fn new(value: Option<Self::Value>) -> Self {
|
||||
match value {
|
||||
Some(v) => v,
|
||||
None => Self::default_value()
|
||||
}
|
||||
}
|
||||
|
||||
fn setting_name() -> &'static str {
|
||||
stringify!($name)
|
||||
}
|
||||
|
||||
fn toml_path() -> Option<&'static str> {
|
||||
$toml_path_value
|
||||
}
|
||||
|
||||
fn storage_key() -> &'static str {
|
||||
$storage_key
|
||||
}
|
||||
|
||||
fn toml_key() -> &'static str {
|
||||
const KEY: &str = match $toml_path_value {
|
||||
Some(path) => $crate::toml_path_storage_key(path),
|
||||
None => $storage_key,
|
||||
};
|
||||
KEY
|
||||
}
|
||||
|
||||
fn hierarchy() -> Option<&'static str> {
|
||||
const HIER: Option<&str> = match $toml_path_value {
|
||||
Some(path) => $crate::toml_path_hierarchy(path),
|
||||
None => None,
|
||||
};
|
||||
HIER
|
||||
}
|
||||
|
||||
fn sync_to_cloud() -> $crate::SyncToCloud {
|
||||
$sync_to_cloud
|
||||
}
|
||||
|
||||
fn is_private() -> bool {
|
||||
$private
|
||||
}
|
||||
|
||||
fn supported_platforms() -> SupportedPlatforms {
|
||||
$supported_platforms
|
||||
}
|
||||
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self
|
||||
}
|
||||
|
||||
fn clear_value(
|
||||
&mut self,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
Self::clear_from_preferences(Self::preferences_for_setting(ctx))?;
|
||||
*self = self.validate(Self::default_value());
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::Clear,
|
||||
}}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value_from_cloud_sync(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
let changed_in_storage =
|
||||
Self::write_to_preferences(&new_value, Self::preferences_for_setting(ctx))?;
|
||||
if self.value() != &new_value || changed_in_storage {
|
||||
*self = self.validate(new_value);
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::CloudSync,
|
||||
}}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
let changed_in_storage =
|
||||
Self::write_to_preferences(&new_value, Self::preferences_for_setting(ctx))?;
|
||||
if self.value() != &new_value || changed_in_storage {
|
||||
*self = self.validate(new_value);
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::LocalChange,
|
||||
}}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_value(
|
||||
&mut self,
|
||||
new_value: Self::Value,
|
||||
_explicitly_set: bool,
|
||||
ctx: &mut warpui::ModelContext<Self::Group>,
|
||||
) -> anyhow::Result<()> {
|
||||
use $crate::ChangeEventReason;
|
||||
let validated = self.validate(new_value);
|
||||
if self.value() != &validated {
|
||||
*self = validated;
|
||||
ctx.emit($crate::macros::concat_idents!(EventName = $group, ChangedEvent { EventName::$name {
|
||||
change_event_reason: ChangeEventReason::LocalChange,
|
||||
}}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_value() -> Self::Value {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn is_supported_on_current_platform(&self) -> bool {
|
||||
$supported_platforms.matches_current_platform()
|
||||
}
|
||||
|
||||
fn is_value_explicitly_set(&self) -> bool {
|
||||
// For enums using implement_setting_for_enum, we don't track explicit setting
|
||||
// TODO(advait): deprecate this in favour of struct settings in a follow-up PR.
|
||||
true
|
||||
}
|
||||
|
||||
$(
|
||||
fn max_table_depth() -> Option<u32> {
|
||||
Some($mtd)
|
||||
}
|
||||
)?
|
||||
}
|
||||
|
||||
$crate::submit_schema_entry!(
|
||||
private: $private,
|
||||
description: $crate::_schema_default_description!($($desc)?),
|
||||
toml_path_value: $toml_path_value,
|
||||
fallback_storage_key: $storage_key,
|
||||
supported_platforms: $supported_platforms,
|
||||
feature_flag: $crate::_schema_default_flag!($($flag)?),
|
||||
max_table_depth: $crate::_schema_default_max_table_depth!($($mtd)?),
|
||||
default: { <$name as Default>::default() },
|
||||
value_type: $name
|
||||
);
|
||||
};
|
||||
// toml_path + max_table_depth
|
||||
($name:ident, $group:path, $supported_platforms:expr, $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr, max_table_depth: $mtd:literal $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)?) => {
|
||||
$crate::macros::implement_setting_for_enum!(@base $name, $group, $supported_platforms, $sync_to_cloud, private: $private, storage_key: stringify!($name), toml_path_value: Some($toml_path), max_table_depth_value: $mtd $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// toml_path only
|
||||
($name:ident, $group:path, $supported_platforms:expr, $sync_to_cloud:expr, private: $private:expr, toml_path: $toml_path:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)?) => {
|
||||
$crate::macros::implement_setting_for_enum!(@base $name, $group, $supported_platforms, $sync_to_cloud, private: $private, storage_key: stringify!($name), toml_path_value: Some($toml_path) $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
// neither (private settings)
|
||||
($name:ident, $group:path, $supported_platforms:expr, $sync_to_cloud:expr, private: $private:expr $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)?) => {
|
||||
$crate::macros::implement_setting_for_enum!(@base $name, $group, $supported_platforms, $sync_to_cloud, private: $private, storage_key: stringify!($name), toml_path_value: None::<&str> $(, description: $desc)? $(, feature_flag: $flag)?);
|
||||
};
|
||||
}
|
||||
pub use implement_setting_for_enum;
|
||||
|
||||
/// By defining a trait that the settings groups implement, we're able to call
|
||||
/// methods like `is_supported_on_current_platform()` without knowing the exact settings
|
||||
/// group we're operating on at compile time.
|
||||
pub trait SettingSection {
|
||||
fn is_supported_on_current_platform(&self) -> bool;
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! define_settings_group {
|
||||
($group:ident, settings: [$($var:ident: $setting:ident $({ type: $value_type:ty, default: $default:expr, supported_platforms: $supported_platforms:expr, sync_to_cloud: $sync_to_cloud:expr, private: $private:expr $(, storage_key: $storage_key:literal)? $(, toml_path: $toml_path:literal)? $(, max_table_depth: $mtd:literal)? $(, description: $desc:literal)? $(, feature_flag: $flag:path)? $(,)? })? $(,)? )*]) => {
|
||||
$(
|
||||
$crate::macros::maybe_define_setting!($setting, group: $group $(, { type: $value_type, default: $default, supported_platforms: $supported_platforms, sync_to_cloud: $sync_to_cloud, private: $private $(, storage_key: $storage_key)? $(, toml_path: $toml_path)? $(, max_table_depth: $mtd)? $(, description: $desc)? $(, feature_flag: $flag)? })?);
|
||||
)*
|
||||
|
||||
pub struct $group {
|
||||
$(
|
||||
pub $var: $setting,
|
||||
)*
|
||||
}
|
||||
|
||||
impl $group {
|
||||
#[allow(dead_code)]
|
||||
fn new_from_storage(ctx: &mut warpui::ModelContext<Self>) -> Self {
|
||||
use $crate::Setting;
|
||||
Self {
|
||||
$(
|
||||
$var: <$setting>::new_from_storage(ctx),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "integration_tests"))]
|
||||
#[allow(dead_code)]
|
||||
pub fn new_with_defaults(_ctx: &mut warpui::ModelContext<Self>) -> Self {
|
||||
use $crate::Setting;
|
||||
Self {
|
||||
$(
|
||||
$var: <$setting>::new(None),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn register(ctx: &mut (impl warpui::GetSingletonModelHandle + warpui::AddSingletonModel + warpui::UpdateModel)) -> warpui::ModelHandle<Self> {
|
||||
let settings_group = ctx.add_singleton_model(|ctx| {
|
||||
Self::new_from_storage(ctx)
|
||||
});
|
||||
|
||||
// Wire up settings event update functions for all settings
|
||||
$(
|
||||
$crate::macros::register_settings_events!(
|
||||
$group,
|
||||
$var,
|
||||
$setting,
|
||||
settings_group.clone(),
|
||||
ctx
|
||||
);
|
||||
)*
|
||||
settings_group
|
||||
}
|
||||
}
|
||||
|
||||
impl $crate::macros::SettingSection for $group {
|
||||
/// If any of the settings in the setting group are supported, then the group is supported.
|
||||
/// If none of the settings in the group are supported, then the group is not supported.
|
||||
fn is_supported_on_current_platform(&self) -> bool {
|
||||
use $crate::Setting;
|
||||
$(
|
||||
if self.$var.is_supported_on_current_platform() {
|
||||
return true;
|
||||
}
|
||||
)*
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
$crate::macros::concat_idents!(EventName = $group, ChangedEvent {
|
||||
use $crate::ChangeEventReason;
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum EventName {
|
||||
$(
|
||||
$setting {
|
||||
#[allow(dead_code)]
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
)*
|
||||
}
|
||||
|
||||
impl warpui::Entity for $group {
|
||||
type Event = EventName;
|
||||
}
|
||||
});
|
||||
|
||||
impl warpui::SingletonEntity for $group {}
|
||||
};
|
||||
}
|
||||
pub use define_settings_group;
|
||||
|
||||
/// Registers listeners for settings events that get piped through the
|
||||
/// SettingsManager. These events allow for anyone to listen to settings
|
||||
/// changes based on storage key rather than individual settings models.
|
||||
#[macro_export]
|
||||
macro_rules! register_settings_events {
|
||||
( $group:ident, $var:ident, $setting:ident, $handle:expr, $ctx:expr ) => {{
|
||||
$crate::macros::generate_settings_event_fn!($group, $var, $setting);
|
||||
|
||||
concat_idents::concat_idents!(fn_name = register_events_for_, $setting, {
|
||||
fn_name($handle, $ctx);
|
||||
});
|
||||
}};
|
||||
}
|
||||
pub use register_settings_events;
|
||||
|
||||
/// Generates a function that can be used to register event handlers for a
|
||||
/// for letting the SettingsManager know when a setting has been updated.
|
||||
/// Used for managing the flow of events for local and cloud settings.
|
||||
#[macro_export]
|
||||
macro_rules! generate_settings_event_fn {
|
||||
( $group:ident, $var:ident, $setting:ident ) => {
|
||||
concat_idents::concat_idents!(fn_name = register_events_for_, $setting, {
|
||||
#[allow(dead_code)]
|
||||
#[allow(non_snake_case)]
|
||||
fn fn_name(
|
||||
settings_group: warpui::ModelHandle<$group>,
|
||||
ctx: &mut (
|
||||
impl warpui::GetSingletonModelHandle
|
||||
+ warpui::AddSingletonModel
|
||||
+ warpui::UpdateModel
|
||||
),
|
||||
) {
|
||||
use anyhow::anyhow;
|
||||
use serde_json;
|
||||
use warpui::SingletonEntity;
|
||||
use $crate::Setting as _;
|
||||
use $crate::manager::{SettingsEvent, SettingsManager};
|
||||
SettingsManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
// Propagate per settings change events through the SettingsManager
|
||||
ctx.subscribe_to_model(&settings_group, |_manager, _, ctx| {
|
||||
ctx.emit(SettingsEvent::LocalPreferencesUpdated {
|
||||
storage_key: $setting::storage_key().to_string(),
|
||||
sync_to_cloud: $setting::sync_to_cloud(),
|
||||
});
|
||||
});
|
||||
// Register callbacks for updating individual settings model by storage key
|
||||
let settings_group_update_clone = settings_group.clone();
|
||||
let settings_group_reset_clone = settings_group.clone();
|
||||
let settings_group_load_clone = settings_group.clone();
|
||||
let settings_group_is_syncable_clone = settings_group.clone();
|
||||
let serialized_default_value =
|
||||
serde_json::to_string(&$setting::default_value())
|
||||
.expect("default should serialize");
|
||||
let file_serialized_default_value = {
|
||||
use $crate::_settings_value::SettingsValue as _;
|
||||
let file_value = $setting::default_value().to_file_value();
|
||||
serde_json::to_string(&file_value)
|
||||
.expect("default file value should serialize")
|
||||
};
|
||||
manager.register_setting(
|
||||
$setting::storage_key(),
|
||||
$setting::sync_to_cloud(),
|
||||
$setting::supported_platforms(),
|
||||
serialized_default_value,
|
||||
file_serialized_default_value,
|
||||
$setting::hierarchy(),
|
||||
$setting::toml_key(),
|
||||
$setting::max_table_depth(),
|
||||
$setting::is_private(),
|
||||
move |value, from_cloud_sync, ctx| {
|
||||
use $crate::_settings_value::SettingsValue as _;
|
||||
// Try SettingsValue first (handles snake_case enums etc.),
|
||||
// then fall back to serde for cloud sync values.
|
||||
let value = serde_json::from_str::<serde_json::Value>(&value)
|
||||
.ok()
|
||||
.and_then(|json_val| {
|
||||
<$setting as $crate::Setting>::Value::from_file_value(&json_val)
|
||||
})
|
||||
.or_else(|| serde_json::from_str(&value).ok());
|
||||
let Some(value) = value else {
|
||||
return Err(anyhow!(
|
||||
"Failed to parse updated value for setting {}: Not updating",
|
||||
$setting::storage_key()
|
||||
));
|
||||
};
|
||||
settings_group_update_clone.update(ctx, |settings_group, ctx| {
|
||||
if from_cloud_sync {
|
||||
settings_group.$var.set_value_from_cloud_sync(value, ctx)
|
||||
} else {
|
||||
settings_group.$var.set_value(value, ctx)
|
||||
}
|
||||
})
|
||||
},
|
||||
move |ctx| {
|
||||
settings_group_reset_clone.update(ctx, |settings_group, ctx| {
|
||||
if settings_group
|
||||
.$var
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
{
|
||||
log::debug!(
|
||||
"Clearing cloud synced setting from local storage: {}",
|
||||
$setting::storage_key()
|
||||
);
|
||||
settings_group.$var.clear_value(ctx)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
},
|
||||
move |value, explicitly_set, ctx| {
|
||||
use $crate::_settings_value::SettingsValue as _;
|
||||
let value = serde_json::from_str::<serde_json::Value>(&value)
|
||||
.ok()
|
||||
.and_then(|json_val| {
|
||||
<$setting as $crate::Setting>::Value::from_file_value(&json_val)
|
||||
})
|
||||
.or_else(|| serde_json::from_str(&value).ok());
|
||||
let Some(value) = value else {
|
||||
return Err(anyhow!(
|
||||
"Failed to parse loaded value for setting {}: Not loading",
|
||||
$setting::storage_key()
|
||||
));
|
||||
};
|
||||
settings_group_load_clone.update(ctx, |settings_group, ctx| {
|
||||
settings_group.$var.load_value(value, explicitly_set, ctx)
|
||||
})
|
||||
},
|
||||
|left, right| {
|
||||
use $crate::_settings_value::SettingsValue as _;
|
||||
let parse =
|
||||
|s: &str| -> anyhow::Result<<$setting as $crate::Setting>::Value> {
|
||||
let json_val = serde_json::from_str::<serde_json::Value>(s)?;
|
||||
<$setting as $crate::Setting>::Value::from_file_value(&json_val)
|
||||
.or_else(|| serde_json::from_str(s).ok())
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Failed to parse value for {}",
|
||||
$setting::storage_key()
|
||||
)
|
||||
})
|
||||
};
|
||||
let left_setting = $setting::new(Some(parse(left)?));
|
||||
let right_setting = $setting::new(Some(parse(right)?));
|
||||
Ok(left_setting.value() == right_setting.value())
|
||||
},
|
||||
move |ctx| {
|
||||
settings_group_is_syncable_clone
|
||||
.as_ref(ctx)
|
||||
.$var
|
||||
.current_value_is_syncable()
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
pub use generate_settings_event_fn;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "macros_tests.rs"]
|
||||
mod macros_tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,435 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
use warpui_extras::user_preferences::UserPreferences;
|
||||
|
||||
use super::PrivatePreferences;
|
||||
|
||||
use super::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
type UpdateFn = Box<dyn FnMut(String, bool, &mut AppContext) -> Result<()>>;
|
||||
|
||||
type ClearFn = Box<dyn FnMut(&mut AppContext) -> Result<()>>;
|
||||
|
||||
/// Loads a value into memory without persisting. Parameters: (serialized_value, explicitly_set, ctx).
|
||||
type LoadFn = Box<dyn FnMut(String, bool, &mut AppContext) -> Result<()>>;
|
||||
|
||||
type EqualsFn = Box<dyn Fn(&str, &str) -> Result<bool>>;
|
||||
|
||||
type IsSyncableFn = Box<dyn Fn(&AppContext) -> bool>;
|
||||
|
||||
/// Intermediate data collected for each setting during reload, before
|
||||
/// calling the mutable `load_fns`.
|
||||
struct SettingReloadEntry {
|
||||
storage_key: String,
|
||||
read_value: Option<String>,
|
||||
serialized_default: String,
|
||||
toml_key: &'static str,
|
||||
hierarchy: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SettingsInfo {
|
||||
sync_to_cloud: SyncToCloud,
|
||||
supported_platforms: SupportedPlatforms,
|
||||
serialized_default_value: String,
|
||||
/// The default value serialized using the `SettingsValue` trait
|
||||
/// for the settings file.
|
||||
file_serialized_default_value: String,
|
||||
hierarchy: Option<&'static str>,
|
||||
/// The key used in the TOML settings file (last segment of `toml_path`).
|
||||
/// For settings without a `toml_path`, this equals the storage key.
|
||||
toml_key: &'static str,
|
||||
/// The maximum number of TOML section-table levels to use when rendering
|
||||
/// this setting's value in the settings file. `None` means unlimited.
|
||||
max_table_depth: Option<u32>,
|
||||
/// Whether this setting is private (not shown in the user-visible settings file).
|
||||
is_private: bool,
|
||||
}
|
||||
|
||||
/// Provides an interface for listening for settings events based on
|
||||
/// storage key and also for updating settings based on storage key.
|
||||
///
|
||||
/// Practically speaking this struct is used for keeping local and
|
||||
/// cloud preferences in sync with each other without creating a direct
|
||||
/// dependency between the define_settings_group macros and the
|
||||
/// cloud preferences syncing machinery.
|
||||
#[derive(Default)]
|
||||
pub struct SettingsManager {
|
||||
/// Settings info by storage key
|
||||
settings: HashMap<String, SettingsInfo>,
|
||||
|
||||
/// Functions for updating settings by storage key
|
||||
update_fns: HashMap<String, UpdateFn>,
|
||||
|
||||
/// Functions for clearing settings from local storage (which also effectively resets them to their default value)
|
||||
clear_fns: HashMap<String, ClearFn>,
|
||||
|
||||
/// Functions for loading a value into memory without persisting to storage.
|
||||
/// Used during hot-reload to avoid write-back loops with the file watcher.
|
||||
load_fns: HashMap<String, LoadFn>,
|
||||
|
||||
/// Functions for checking whether two serialized settings
|
||||
/// with the same storage key have equal values. Note that
|
||||
/// we need this because we can't just compare the deserialized
|
||||
/// or raw json values for equality. This fails because things
|
||||
/// like HashSet serialize to ordered json arrays, but don't have
|
||||
/// a defined order.
|
||||
equals_fns: HashMap<String, EqualsFn>,
|
||||
|
||||
/// Functions for checking whether a setting is currently syncable
|
||||
/// based on its value. Settings that want custom logic here should define
|
||||
/// the current_value_is_syncable method.
|
||||
is_syncable_fns: HashMap<String, IsSyncableFn>,
|
||||
}
|
||||
|
||||
pub enum SettingsEvent {
|
||||
LocalPreferencesUpdated {
|
||||
storage_key: String,
|
||||
sync_to_cloud: SyncToCloud,
|
||||
},
|
||||
}
|
||||
|
||||
impl SettingsManager {
|
||||
/// Registers a function that updates a a setting with the given storage key
|
||||
/// to have a new value. Also tracks whether that storage key is for a cloud-synced
|
||||
/// setting and what platforms it's supported on.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn register_setting(
|
||||
&mut self,
|
||||
storage_key: &str,
|
||||
sync_to_cloud: SyncToCloud,
|
||||
supported_platforms: SupportedPlatforms,
|
||||
serialized_default_value: String,
|
||||
file_serialized_default_value: String,
|
||||
hierarchy: Option<&'static str>,
|
||||
toml_key: &'static str,
|
||||
max_table_depth: Option<u32>,
|
||||
is_private: bool,
|
||||
update_fn: impl FnMut(String, bool, &mut AppContext) -> Result<()> + 'static,
|
||||
clear_fn: impl FnMut(&mut AppContext) -> Result<()> + 'static,
|
||||
load_fn: impl FnMut(String, bool, &mut AppContext) -> Result<()> + 'static,
|
||||
equals_fn: impl Fn(&str, &str) -> Result<bool> + 'static,
|
||||
is_syncable_fn: impl Fn(&AppContext) -> bool + 'static,
|
||||
) {
|
||||
self.update_fns
|
||||
.insert(storage_key.to_owned(), Box::new(update_fn));
|
||||
self.clear_fns
|
||||
.insert(storage_key.to_owned(), Box::new(clear_fn));
|
||||
self.load_fns
|
||||
.insert(storage_key.to_owned(), Box::new(load_fn));
|
||||
self.equals_fns
|
||||
.insert(storage_key.to_owned(), Box::new(equals_fn));
|
||||
self.is_syncable_fns
|
||||
.insert(storage_key.to_owned(), Box::new(is_syncable_fn));
|
||||
self.settings.insert(
|
||||
storage_key.to_owned(),
|
||||
SettingsInfo {
|
||||
supported_platforms,
|
||||
sync_to_cloud,
|
||||
serialized_default_value,
|
||||
file_serialized_default_value,
|
||||
hierarchy,
|
||||
toml_key,
|
||||
max_table_depth,
|
||||
is_private,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Clears all cloud synced settings from the user defaults. Does not affect their cloud state.
|
||||
/// Typically called when a user logs out. Note that the caller is responsible for ensuring that
|
||||
/// cloud preferences are enabled before calling this.
|
||||
pub fn clear_cloud_settings_local_state(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Vec<anyhow::Error> {
|
||||
self.clear_fns
|
||||
.values_mut()
|
||||
.filter_map(|clear_fn| clear_fn(ctx).err())
|
||||
.collect::<Vec<anyhow::Error>>()
|
||||
}
|
||||
|
||||
/// Returns all registered storage keys.
|
||||
pub fn all_storage_keys(&self) -> impl Iterator<Item = &String> {
|
||||
self.settings.keys()
|
||||
}
|
||||
|
||||
/// Returns the storage keys for all public (non-private) settings.
|
||||
pub fn public_storage_keys(&self) -> impl Iterator<Item = &str> + '_ {
|
||||
self.settings
|
||||
.iter()
|
||||
.filter(|(_, info)| !info.is_private)
|
||||
.map(|(key, _)| key.as_str())
|
||||
}
|
||||
|
||||
/// Returns whether the setting with the given storage key should be synced even if the
|
||||
/// user has disabled syncing.
|
||||
pub fn sync_regardless_of_users_syncing_setting(&self, storage_key: &str) -> bool {
|
||||
self.settings
|
||||
.get(storage_key)
|
||||
.map(|info| {
|
||||
matches!(
|
||||
info.sync_to_cloud,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::No)
|
||||
| SyncToCloud::PerPlatform(RespectUserSyncSetting::No)
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns whether the setting with the given storage key has a value that is currently
|
||||
/// syncable to the cloud.
|
||||
pub fn is_current_value_syncable(&self, storage_key: &str, app: &AppContext) -> Result<bool> {
|
||||
self.is_syncable_fns
|
||||
.get(storage_key)
|
||||
.map(|cb| Ok(cb(app)))
|
||||
.unwrap_or_else(|| {
|
||||
Err(anyhow!(
|
||||
"no is_syncable fn registered for storage key {}",
|
||||
storage_key
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the cloud_syncing_mode for the given storage key.
|
||||
pub fn cloud_syncing_mode_for_storage_key(&self, storage_key: &str) -> Option<SyncToCloud> {
|
||||
self.settings
|
||||
.get(storage_key)
|
||||
.map(|info| info.sync_to_cloud)
|
||||
}
|
||||
|
||||
/// Returns the supported platforms for this storage key.
|
||||
pub fn supported_platforms_for_storage_key(
|
||||
&self,
|
||||
storage_key: &str,
|
||||
) -> Option<&SupportedPlatforms> {
|
||||
self.settings
|
||||
.get(storage_key)
|
||||
.map(|info| &info.supported_platforms)
|
||||
}
|
||||
|
||||
/// Returns whether the setting with the given storage key is private.
|
||||
pub fn is_private_for_storage_key(&self, storage_key: &str) -> bool {
|
||||
self.settings
|
||||
.get(storage_key)
|
||||
.map(|info| info.is_private)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reads a setting's current local value from the correct preferences
|
||||
/// backend, routing private settings to the private store and public
|
||||
/// settings to the main (potentially TOML-backed) store.
|
||||
pub fn read_local_setting_value(
|
||||
&self,
|
||||
storage_key: &str,
|
||||
ctx: &AppContext,
|
||||
) -> Result<Option<String>> {
|
||||
let private: &dyn UserPreferences =
|
||||
<PrivatePreferences as SingletonEntity>::as_ref(ctx).deref();
|
||||
let prefs: &dyn UserPreferences = if self.is_private_for_storage_key(storage_key) {
|
||||
private
|
||||
} else if super::is_settings_file_enabled() {
|
||||
<super::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences()
|
||||
} else {
|
||||
// When the settings file is disabled, fall back to the private
|
||||
// backend so both paths share a single instance.
|
||||
private
|
||||
};
|
||||
let info = self.settings.get(storage_key);
|
||||
let key = if prefs.is_settings_file() {
|
||||
info.map_or(storage_key, |i| i.toml_key)
|
||||
} else {
|
||||
storage_key
|
||||
};
|
||||
let hierarchy = info.and_then(|i| i.hierarchy);
|
||||
prefs
|
||||
.read_value_with_hierarchy(key, hierarchy)
|
||||
.map_err(|e| anyhow!("failed to read setting {storage_key}: {e}"))
|
||||
}
|
||||
|
||||
/// Updates the setting with the given storage key to a new value, returning
|
||||
/// a result indicating whether the update was successful.
|
||||
pub fn update_setting_with_storage_key(
|
||||
&mut self,
|
||||
storage_key: &str,
|
||||
new_value: String,
|
||||
from_cloud_sync: bool,
|
||||
ctx: &mut AppContext,
|
||||
) -> Result<()> {
|
||||
self.update_fns
|
||||
.get_mut(storage_key)
|
||||
.map(|update_fn| update_fn(new_value, from_cloud_sync, ctx))
|
||||
.unwrap_or_else(|| {
|
||||
Err(anyhow!(
|
||||
"no update fn registered for storage key {}",
|
||||
storage_key
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns whether the two serialized settings with the given storage key
|
||||
/// have equal values. This isn't a direct string comparison, or even a comparison
|
||||
/// of JSON values, but a comparison using the Setting.value()'s equality method.
|
||||
pub fn are_equal_settings(&self, storage_key: &str, left: &str, right: &str) -> Result<bool> {
|
||||
self.equals_fns
|
||||
.get(storage_key)
|
||||
.map(|equality_fn| equality_fn(left, right))
|
||||
.unwrap_or_else(|| {
|
||||
Err(anyhow!(
|
||||
"no equals fn registered for storage key {}",
|
||||
storage_key
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_values(&self) -> impl Iterator<Item = (String, String)> + '_ {
|
||||
self.settings
|
||||
.iter()
|
||||
.map(|(key, info)| (key.clone(), info.serialized_default_value.clone()))
|
||||
}
|
||||
|
||||
/// Loads a setting value into memory without persisting to storage.
|
||||
///
|
||||
/// `explicitly_set` indicates whether the value came from the file (`true`)
|
||||
/// or is a default for an absent key (`false`).
|
||||
pub fn load_setting(
|
||||
&mut self,
|
||||
storage_key: &str,
|
||||
value: String,
|
||||
explicitly_set: bool,
|
||||
ctx: &mut AppContext,
|
||||
) -> Result<()> {
|
||||
self.load_fns
|
||||
.get_mut(storage_key)
|
||||
.map(|load_fn| load_fn(value, explicitly_set, ctx))
|
||||
.unwrap_or_else(|| {
|
||||
Err(anyhow!(
|
||||
"no load fn registered for storage key {}",
|
||||
storage_key
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Reloads all public (non-private) settings from the preferences backend.
|
||||
///
|
||||
/// Call this after the backing store has been refreshed from disk (e.g.
|
||||
/// via [`UserPreferences::reload_from_disk`]) so that every in-memory
|
||||
/// setting picks up the new values.
|
||||
///
|
||||
/// Uses [`load_setting`](Self::load_setting) to update in-memory values
|
||||
/// without writing back to the preferences backend, avoiding write-back
|
||||
/// loops with the file watcher. Keys present in the file are loaded with
|
||||
/// `explicitly_set = true`; absent keys are reset to their default with
|
||||
/// `explicitly_set = false`.
|
||||
/// Returns the storage keys of any settings that failed to load.
|
||||
pub fn reload_all_public_settings(&mut self, ctx: &mut AppContext) -> Vec<String> {
|
||||
let prefs = <super::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
|
||||
// Read every non-private setting from the (now-reloaded) preferences,
|
||||
// collecting them up-front to release the immutable borrow on
|
||||
// `self.settings` before calling the mutable `load_fns`.
|
||||
let updates: Vec<SettingReloadEntry> = self
|
||||
.settings
|
||||
.iter()
|
||||
.filter(|(_, info)| !info.is_private)
|
||||
.map(|(key, info)| {
|
||||
let read_value =
|
||||
match prefs.read_value_with_hierarchy(info.toml_key, info.hierarchy) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to read setting {key} during reload: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
SettingReloadEntry {
|
||||
storage_key: key.clone(),
|
||||
read_value,
|
||||
serialized_default: info.serialized_default_value.clone(),
|
||||
toml_key: info.toml_key,
|
||||
hierarchy: info.hierarchy,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut failed_keys = Vec::new();
|
||||
for entry in updates {
|
||||
let (effective_value, explicitly_set) = match entry.read_value {
|
||||
Some(v) => (v, true),
|
||||
None => (entry.serialized_default, false),
|
||||
};
|
||||
if let Err(err) =
|
||||
self.load_setting(&entry.storage_key, effective_value, explicitly_set, ctx)
|
||||
{
|
||||
log::warn!("Failed to reload setting {}: {err}", entry.storage_key);
|
||||
// Re-inhibit this key so writes don't overwrite the
|
||||
// user's broken-but-fixable value in the file.
|
||||
let prefs =
|
||||
<super::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
prefs.inhibit_writes_for_key(entry.toml_key, entry.hierarchy);
|
||||
failed_keys.push(entry.toml_key.to_string());
|
||||
}
|
||||
}
|
||||
failed_keys
|
||||
}
|
||||
|
||||
/// Validates all public settings by reading each from the preferences
|
||||
/// backend and attempting deserialization. Returns the storage keys of
|
||||
/// any settings whose stored value cannot be deserialized.
|
||||
///
|
||||
/// This is a read-only check — it does not modify in-memory state.
|
||||
/// Call after [`register_all_settings`] on startup to detect invalid
|
||||
/// values in the settings file.
|
||||
pub fn validate_all_public_settings(&self, ctx: &AppContext) -> Vec<String> {
|
||||
let prefs = <super::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
|
||||
self.settings
|
||||
.iter()
|
||||
.filter(|(_, info)| !info.is_private)
|
||||
.filter_map(|(key, info)| {
|
||||
let value = prefs
|
||||
.read_value_with_hierarchy(info.toml_key, info.hierarchy)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
|
||||
// Try deserializing through the equals_fn — if serde_json
|
||||
// can't parse both sides, the value is invalid.
|
||||
if let Some(equals_fn) = self.equals_fns.get(key)
|
||||
&& equals_fn(&value, &value).is_err()
|
||||
{
|
||||
return Some(info.toml_key.to_string());
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns all registered settings with their toml key, serialized
|
||||
/// default value (in file format), hierarchy path, and max table depth,
|
||||
/// for use when writing the user-visible settings file.
|
||||
pub fn default_values_for_settings_file(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (&str, &str, Option<&'static str>, Option<u32>)> + '_ {
|
||||
self.settings
|
||||
.iter()
|
||||
.filter(|(_, info)| !info.is_private)
|
||||
.map(|(_, info)| {
|
||||
(
|
||||
info.toml_key,
|
||||
info.file_serialized_default_value.as_str(),
|
||||
info.hierarchy,
|
||||
info.max_table_depth,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SettingsManager {
|
||||
type Event = SettingsEvent;
|
||||
}
|
||||
|
||||
/// Mark SettingsManager as global application state.
|
||||
impl SingletonEntity for SettingsManager {}
|
||||
@@ -0,0 +1,635 @@
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
use crate::manager::SettingsManager;
|
||||
use crate::{Setting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
use crate::*;
|
||||
|
||||
define_settings_group!(TestSettings, settings: [
|
||||
never_sync_setting: SimpleSetting {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "test.simple_setting",
|
||||
},
|
||||
global_setting: GlobalSetting {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "test.global_setting",
|
||||
},
|
||||
global_setting_no_respect: GlobalSettingNoRespect {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: false,
|
||||
toml_path: "test.global_setting_no_respect",
|
||||
},
|
||||
per_platform_setting: PerPlatformSetting {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::MAC,
|
||||
sync_to_cloud: SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "test.per_platform_setting",
|
||||
},
|
||||
per_platform_setting_no_respect: PerPlatformSettingNoRespect {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::MAC,
|
||||
sync_to_cloud: SyncToCloud::PerPlatform(RespectUserSyncSetting::No),
|
||||
private: false,
|
||||
toml_path: "test.per_platform_setting_no_respect",
|
||||
},
|
||||
]);
|
||||
|
||||
pub fn init_and_register_user_preferences(ctx: &mut AppContext) {
|
||||
ctx.add_singleton_model(move |_| {
|
||||
crate::PublicPreferences::new(Box::<
|
||||
warpui_extras::user_preferences::in_memory::InMemoryPreferences,
|
||||
>::default())
|
||||
});
|
||||
ctx.add_singleton_model(move |_| {
|
||||
crate::PrivatePreferences::new(Box::<
|
||||
warpui_extras::user_preferences::in_memory::InMemoryPreferences,
|
||||
>::default())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_setting_syncable_on_current_platform() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_and_register_user_preferences);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
|
||||
// Register our TestSettings settings group with the app.
|
||||
TestSettings::register(&mut app);
|
||||
|
||||
app.read(|app| {
|
||||
let settings = TestSettings::as_ref(app);
|
||||
assert!(
|
||||
!settings
|
||||
.never_sync_setting
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
);
|
||||
assert!(
|
||||
!settings
|
||||
.never_sync_setting
|
||||
.is_setting_syncable_on_current_platform(false)
|
||||
);
|
||||
|
||||
assert!(
|
||||
settings
|
||||
.global_setting
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
);
|
||||
assert!(
|
||||
!settings
|
||||
.global_setting
|
||||
.is_setting_syncable_on_current_platform(false)
|
||||
);
|
||||
|
||||
assert!(
|
||||
settings
|
||||
.global_setting_no_respect
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
);
|
||||
assert!(
|
||||
settings
|
||||
.global_setting_no_respect
|
||||
.is_setting_syncable_on_current_platform(false)
|
||||
);
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(
|
||||
settings
|
||||
.per_platform_setting
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
);
|
||||
assert!(
|
||||
!settings
|
||||
.per_platform_setting
|
||||
.is_setting_syncable_on_current_platform(false)
|
||||
);
|
||||
|
||||
assert!(
|
||||
settings
|
||||
.per_platform_setting_no_respect
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
);
|
||||
assert!(
|
||||
settings
|
||||
.per_platform_setting_no_respect
|
||||
.is_setting_syncable_on_current_platform(false)
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
!settings
|
||||
.per_platform_setting
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
);
|
||||
assert!(
|
||||
!settings
|
||||
.per_platform_setting
|
||||
.is_setting_syncable_on_current_platform(false)
|
||||
);
|
||||
|
||||
assert!(
|
||||
!settings
|
||||
.per_platform_setting_no_respect
|
||||
.is_setting_syncable_on_current_platform(true)
|
||||
);
|
||||
assert!(
|
||||
!settings
|
||||
.per_platform_setting_no_respect
|
||||
.is_setting_syncable_on_current_platform(false)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
mod reload_all_public_settings_tests {
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
use crate::manager::SettingsManager;
|
||||
use crate::{Setting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
use crate::*;
|
||||
|
||||
define_settings_group!(ReloadTestSettings, settings: [
|
||||
public_flag: PublicFlag {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "test.public_flag",
|
||||
},
|
||||
private_flag: PrivateFlag {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
|
||||
fn init_prefs(ctx: &mut AppContext) {
|
||||
ctx.add_singleton_model(move |_| -> crate::PublicPreferences {
|
||||
crate::PublicPreferences::new(Box::<
|
||||
warpui_extras::user_preferences::in_memory::InMemoryPreferences,
|
||||
>::default())
|
||||
});
|
||||
ctx.add_singleton_model(move |_| -> crate::PrivatePreferences {
|
||||
crate::PrivatePreferences(Box::<
|
||||
warpui_extras::user_preferences::in_memory::InMemoryPreferences,
|
||||
>::default())
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that `reload_all_public_settings` picks up values present
|
||||
/// in the preferences backend.
|
||||
#[test]
|
||||
fn test_loads_present_keys() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_prefs);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
ReloadTestSettings::register(&mut app);
|
||||
|
||||
// Write a non-default value directly to the public backend.
|
||||
app.update(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
public
|
||||
.write_value("public_flag", "true".to_string())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Reload — the in-memory value should update.
|
||||
app.update(|ctx| {
|
||||
SettingsManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.reload_all_public_settings(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
*ReloadTestSettings::as_ref(ctx).public_flag.value(),
|
||||
"reload should load present key from preferences"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that absent keys are reset to their default values during
|
||||
/// reload (the key-deletion scenario).
|
||||
#[test]
|
||||
fn test_resets_absent_keys_to_defaults() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_prefs);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
ReloadTestSettings::register(&mut app);
|
||||
|
||||
// Set a non-default value via set_value (updates both in-memory and storage).
|
||||
app.update(|ctx| {
|
||||
ReloadTestSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
settings.public_flag.set_value(true, ctx).unwrap();
|
||||
});
|
||||
});
|
||||
|
||||
// Remove the key from storage (simulates the user deleting a key from
|
||||
// the settings file, then reload_from_disk picking up the deletion).
|
||||
app.update(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
public.remove_value("public_flag").unwrap();
|
||||
});
|
||||
|
||||
// Reload — the in-memory value should reset to default.
|
||||
app.update(|ctx| {
|
||||
SettingsManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.reload_all_public_settings(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
*ReloadTestSettings::as_ref(ctx).public_flag.value(),
|
||||
PublicFlag::default_value(),
|
||||
"absent key should be reset to default"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that reload does NOT write absent keys back to storage.
|
||||
/// This is the property that prevents the infinite watcher loop.
|
||||
#[test]
|
||||
fn test_absent_keys_are_not_written_back() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_prefs);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
ReloadTestSettings::register(&mut app);
|
||||
|
||||
// Set a non-default value, then remove from storage.
|
||||
app.update(|ctx| {
|
||||
ReloadTestSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
settings.public_flag.set_value(true, ctx).unwrap();
|
||||
});
|
||||
});
|
||||
app.update(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
public.remove_value("public_flag").unwrap();
|
||||
});
|
||||
|
||||
// Reload.
|
||||
app.update(|ctx| {
|
||||
SettingsManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.reload_all_public_settings(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
// Storage should still be empty — reload must not write back.
|
||||
app.read(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
let stored = public.read_value("public_flag").unwrap();
|
||||
assert!(
|
||||
stored.is_none(),
|
||||
"reload should not write absent keys back to storage"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that `reload_all_public_settings` returns the storage keys
|
||||
/// of settings that fail to deserialize (invalid value in file).
|
||||
#[test]
|
||||
fn test_reload_returns_failed_keys_for_invalid_values() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_prefs);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
ReloadTestSettings::register(&mut app);
|
||||
|
||||
// Write an invalid value for the public bool setting.
|
||||
app.update(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
public
|
||||
.write_value("public_flag", "not_a_bool".to_string())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Reload — should return the failed key.
|
||||
let failed_keys = app.update(|ctx| {
|
||||
SettingsManager::handle(ctx)
|
||||
.update(ctx, |manager, ctx| manager.reload_all_public_settings(ctx))
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
failed_keys,
|
||||
vec!["public_flag".to_string()],
|
||||
"reload should return the key that failed to deserialize"
|
||||
);
|
||||
|
||||
// The setting should remain at its default (not crash).
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
*ReloadTestSettings::as_ref(ctx).public_flag.value(),
|
||||
PublicFlag::default_value(),
|
||||
"setting should remain at default after failed reload"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that `reload_all_public_settings` returns an empty vec
|
||||
/// when all values are valid.
|
||||
#[test]
|
||||
fn test_reload_returns_empty_vec_on_success() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_prefs);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
ReloadTestSettings::register(&mut app);
|
||||
|
||||
// Write a valid value.
|
||||
app.update(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
public
|
||||
.write_value("public_flag", "true".to_string())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let failed_keys = app.update(|ctx| {
|
||||
SettingsManager::handle(ctx)
|
||||
.update(ctx, |manager, ctx| manager.reload_all_public_settings(ctx))
|
||||
});
|
||||
|
||||
assert!(
|
||||
failed_keys.is_empty(),
|
||||
"reload should return empty vec when all values are valid"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that `validate_all_public_settings` detects invalid stored
|
||||
/// values without modifying in-memory state.
|
||||
#[test]
|
||||
fn test_validate_detects_invalid_values() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
crate::set_settings_file_enabled(true);
|
||||
app.update(init_prefs);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
ReloadTestSettings::register(&mut app);
|
||||
|
||||
// Write an invalid value directly to the public preferences.
|
||||
app.update(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
public
|
||||
.write_value("public_flag", "not_valid_json_bool".to_string())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let invalid_keys =
|
||||
app.read(|ctx| SettingsManager::as_ref(ctx).validate_all_public_settings(ctx));
|
||||
|
||||
assert_eq!(
|
||||
invalid_keys,
|
||||
vec!["public_flag".to_string()],
|
||||
"validate should detect the invalid key"
|
||||
);
|
||||
|
||||
// In-memory value should be unchanged (validate is read-only).
|
||||
app.read(|ctx| {
|
||||
assert_eq!(
|
||||
*ReloadTestSettings::as_ref(ctx).public_flag.value(),
|
||||
PublicFlag::default_value(),
|
||||
"validate should not modify in-memory state"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that `validate_all_public_settings` returns empty when all
|
||||
/// stored values are valid.
|
||||
#[test]
|
||||
fn test_validate_returns_empty_when_all_valid() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
crate::set_settings_file_enabled(true);
|
||||
app.update(init_prefs);
|
||||
app.add_singleton_model(|_| SettingsManager::default());
|
||||
ReloadTestSettings::register(&mut app);
|
||||
|
||||
// Write a valid value.
|
||||
app.update(|ctx| {
|
||||
let public =
|
||||
<crate::PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences();
|
||||
public
|
||||
.write_value("public_flag", "true".to_string())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let invalid_keys =
|
||||
app.read(|ctx| SettingsManager::as_ref(ctx).validate_all_public_settings(ctx));
|
||||
|
||||
assert!(
|
||||
invalid_keys.is_empty(),
|
||||
"validate should return empty when all values are valid"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod write_to_preferences_tests {
|
||||
use crate::*;
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
pub struct StructWithOptionals {
|
||||
required_field: String,
|
||||
optional_field: Option<String>,
|
||||
nested: NestedStruct,
|
||||
}
|
||||
|
||||
impl SettingsValue for StructWithOptionals {}
|
||||
|
||||
impl Default for StructWithOptionals {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
required_field: "hello".to_string(),
|
||||
optional_field: None,
|
||||
nested: NestedStruct::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
pub struct NestedStruct {
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl Default for NestedStruct {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 100,
|
||||
height: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_settings_group!(StructTestSettings, settings: [
|
||||
struct_setting: StructSetting {
|
||||
type: StructWithOptionals,
|
||||
default: StructWithOptionals::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "test.struct_setting",
|
||||
},
|
||||
]);
|
||||
|
||||
/// Verifies that `write_to_preferences` does NOT write back when the stored
|
||||
/// JSON differs in formatting (missing null fields, different key ordering)
|
||||
/// but is semantically equal to the new value (in-memory backend).
|
||||
#[test]
|
||||
fn test_no_spurious_write_with_format_differences() {
|
||||
let prefs =
|
||||
Box::<warpui_extras::user_preferences::in_memory::InMemoryPreferences>::default();
|
||||
|
||||
// Simulate what a TOML backend produces after a round-trip:
|
||||
// - null fields (optional_field) are stripped
|
||||
// - key ordering may differ (nested before required_field)
|
||||
let stored_json_without_nulls =
|
||||
r#"{"nested":{"width":100,"height":50},"required_field":"hello"}"#;
|
||||
prefs
|
||||
.write_value("StructSetting", stored_json_without_nulls.to_string())
|
||||
.unwrap();
|
||||
|
||||
// The Rust value has optional_field: None, which serde_json serializes as
|
||||
// `"optional_field":null` with a different key order. The stored JSON
|
||||
// doesn't have that field at all and has different key ordering.
|
||||
let value = StructWithOptionals::default();
|
||||
let canonical_json = serde_json::to_string(&value).unwrap();
|
||||
assert_ne!(
|
||||
canonical_json, stored_json_without_nulls,
|
||||
"precondition: the JSON strings should differ"
|
||||
);
|
||||
|
||||
// write_to_preferences should detect they're semantically equal and NOT write.
|
||||
let changed = StructSetting::write_to_preferences(&value, prefs.as_ref()).unwrap();
|
||||
assert!(
|
||||
!changed,
|
||||
"write_to_preferences should not report a change for semantically equal values"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test with HashMap fields (non-deterministic key order) and missing
|
||||
/// Option fields — reproduces the exact QuakeModeSettings scenario.
|
||||
#[test]
|
||||
fn test_no_spurious_write_with_hashmap_and_missing_options() {
|
||||
use std::collections::HashMap;
|
||||
use warpui_extras::user_preferences::toml_backed::TomlBackedUserPreferences;
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
pub struct QuakeLike {
|
||||
pub keybinding: Option<String>,
|
||||
pub active_pin_position: String,
|
||||
pub sizes: HashMap<String, u32>,
|
||||
pub pin_screen: Option<u32>,
|
||||
pub hide_when_unfocused: bool,
|
||||
}
|
||||
|
||||
impl SettingsValue for QuakeLike {}
|
||||
|
||||
impl Default for QuakeLike {
|
||||
fn default() -> Self {
|
||||
let mut sizes = HashMap::new();
|
||||
sizes.insert("top".to_string(), 30);
|
||||
sizes.insert("bottom".to_string(), 30);
|
||||
Self {
|
||||
keybinding: None,
|
||||
active_pin_position: "Top".to_string(),
|
||||
sizes,
|
||||
pin_screen: None,
|
||||
hide_when_unfocused: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_settings_group!(QuakeLikeGroup, settings: [
|
||||
quake_setting: QuakeLikeSetting {
|
||||
type: QuakeLike,
|
||||
default: QuakeLike::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "test.quake_like_setting",
|
||||
},
|
||||
]);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("settings.toml");
|
||||
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
|
||||
|
||||
let value = QuakeLike::default();
|
||||
|
||||
// First write.
|
||||
let changed = QuakeLikeSetting::write_to_preferences(&value, &prefs).unwrap();
|
||||
assert!(changed, "first write should report a change");
|
||||
|
||||
// Second write of same value should NOT report a change.
|
||||
let changed_again = QuakeLikeSetting::write_to_preferences(&value, &prefs).unwrap();
|
||||
assert!(
|
||||
!changed_again,
|
||||
"second write of same value should not report a change on TOML backend"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same test but using the real TOML backend
|
||||
/// null-stripping and key-reordering happens.
|
||||
#[test]
|
||||
fn test_no_spurious_write_with_toml_backend() {
|
||||
use warpui_extras::user_preferences::toml_backed::TomlBackedUserPreferences;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("settings.toml");
|
||||
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
|
||||
|
||||
let value = StructWithOptionals::default();
|
||||
|
||||
// First write stores the value. The TOML backend will strip the null
|
||||
// `optional_field` and may reorder keys.
|
||||
let changed = StructSetting::write_to_preferences(&value, &prefs).unwrap();
|
||||
assert!(changed, "first write should report a change");
|
||||
|
||||
// Verify the TOML file doesn't contain the null field.
|
||||
let contents = std::fs::read_to_string(&file_path).unwrap();
|
||||
assert!(
|
||||
!contents.contains("optional_field"),
|
||||
"TOML should strip null fields, but file contains: {contents}"
|
||||
);
|
||||
|
||||
// Second write of the same value should NOT report a change,
|
||||
// even though the JSON serialization differs from what's stored.
|
||||
let changed_again = StructSetting::write_to_preferences(&value, &prefs).unwrap();
|
||||
assert!(
|
||||
!changed_again,
|
||||
"second write of same value should not report a change on TOML backend"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Schema metadata for settings, used by the JSON Schema generator.
|
||||
|
||||
use schemars::Schema;
|
||||
use schemars::SchemaGenerator;
|
||||
|
||||
use crate::SupportedPlatforms;
|
||||
|
||||
/// Metadata about a single setting, collected via `inventory` for schema generation.
|
||||
///
|
||||
/// Each setting registered with `define_setting!` or `implement_setting_for_enum!`
|
||||
/// emits an `inventory::submit!` call that registers one of these entries. The
|
||||
/// generator binary iterates all entries to produce a JSON Schema document.
|
||||
pub struct SettingSchemaEntry {
|
||||
/// The storage key for this setting (last segment of toml_path).
|
||||
pub storage_key: &'static str,
|
||||
|
||||
/// User-facing description of what this setting does.
|
||||
pub description: &'static str,
|
||||
|
||||
/// The TOML section path (everything before the last segment of toml_path).
|
||||
pub hierarchy: Option<&'static str>,
|
||||
|
||||
/// Whether this setting is private (excluded from user-facing schema).
|
||||
pub is_private: bool,
|
||||
|
||||
/// Feature flag gating this setting.
|
||||
/// If Some, the setting is only included in the schema when the flag
|
||||
/// is active for the target build channel.
|
||||
pub feature_flag: Option<warp_features::FeatureFlag>,
|
||||
|
||||
/// Returns which platforms this setting applies to.
|
||||
pub supported_platforms_fn: fn() -> SupportedPlatforms,
|
||||
|
||||
/// Returns the default value serialized as JSON.
|
||||
pub default_value_fn: fn() -> String,
|
||||
|
||||
/// Returns the JSON Schema for this setting's value type.
|
||||
pub schema_fn: fn(&mut SchemaGenerator) -> Schema,
|
||||
|
||||
/// Returns the default value serialized using `SettingsValue::to_file_value`.
|
||||
pub file_default_value_fn: fn() -> String,
|
||||
|
||||
/// The maximum number of TOML section-table levels to use when rendering
|
||||
/// this setting's value in the settings file. Mirrors `Setting::max_table_depth`.
|
||||
/// `None` means unlimited depth.
|
||||
pub max_table_depth: Option<u32>,
|
||||
}
|
||||
|
||||
inventory::collect!(SettingSchemaEntry);
|
||||
|
||||
/// Submits a [`SettingSchemaEntry`] to the `inventory` registry.
|
||||
#[macro_export]
|
||||
macro_rules! submit_schema_entry {
|
||||
(
|
||||
private: $private:expr,
|
||||
description: $desc:expr,
|
||||
toml_path_value: $toml_path:expr,
|
||||
fallback_storage_key: $fallback_key:expr,
|
||||
supported_platforms: $plat:expr,
|
||||
feature_flag: $flag:expr,
|
||||
max_table_depth: $mtd:expr,
|
||||
default: $default:tt,
|
||||
value_type: $type:ty $(,)?
|
||||
) => {
|
||||
$crate::_inventory::submit! {
|
||||
$crate::schema::SettingSchemaEntry {
|
||||
storage_key: {
|
||||
const KEY: &str = match $toml_path {
|
||||
Some(path) => $crate::toml_path_storage_key(path),
|
||||
None => $fallback_key,
|
||||
};
|
||||
KEY
|
||||
},
|
||||
description: $desc,
|
||||
hierarchy: {
|
||||
const HIER: Option<&str> = match $toml_path {
|
||||
Some(path) => $crate::toml_path_hierarchy(path),
|
||||
None => None,
|
||||
};
|
||||
HIER
|
||||
},
|
||||
is_private: $private,
|
||||
feature_flag: $flag,
|
||||
supported_platforms_fn: || $plat,
|
||||
default_value_fn: || {
|
||||
let val: $type = $default;
|
||||
serde_json::to_string(&val).expect("default value should serialize")
|
||||
},
|
||||
schema_fn: <$type as $crate::_settings_value::SettingsValue>::file_schema,
|
||||
file_default_value_fn: || {
|
||||
use $crate::_settings_value::SettingsValue as _;
|
||||
let val: $type = $default;
|
||||
let file_value = val.to_file_value();
|
||||
serde_json::to_string(&file_value).expect("default file value should serialize")
|
||||
},
|
||||
max_table_depth: $mtd,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Helper: produces a `&'static str` description, defaulting to `""` when omitted.
|
||||
#[macro_export]
|
||||
macro_rules! _schema_default_description {
|
||||
() => {
|
||||
""
|
||||
};
|
||||
($desc:literal) => {
|
||||
$desc
|
||||
};
|
||||
}
|
||||
|
||||
/// Helper: produces `Option<FeatureFlag>` for a feature flag, defaulting to `None`.
|
||||
#[macro_export]
|
||||
macro_rules! _schema_default_flag {
|
||||
() => {
|
||||
None
|
||||
};
|
||||
($flag:path) => {
|
||||
Some($flag)
|
||||
};
|
||||
}
|
||||
|
||||
/// Helper: produces `Option<u32>` for a max-table-depth literal, defaulting to `None`.
|
||||
#[macro_export]
|
||||
macro_rules! _schema_default_max_table_depth {
|
||||
() => {
|
||||
None
|
||||
};
|
||||
($mtd:literal) => {
|
||||
Some($mtd)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "schema_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use schemars::SchemaGenerator;
|
||||
|
||||
use crate::schema::SettingSchemaEntry;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture settings used for per-type schema validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn entries() -> Vec<&'static SettingSchemaEntry> {
|
||||
inventory::iter::<SettingSchemaEntry>.into_iter().collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invariant tests (run over all registered entries)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn at_least_one_entry_exists() {
|
||||
assert!(
|
||||
!entries().is_empty(),
|
||||
"Expected at least one SettingSchemaEntry to be registered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_empty_storage_keys() {
|
||||
for entry in entries() {
|
||||
assert!(
|
||||
!entry.storage_key.is_empty(),
|
||||
"Found entry with empty storage_key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_defaults_produce_valid_json() {
|
||||
for entry in entries() {
|
||||
let json = (entry.default_value_fn)();
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(&json).is_ok(),
|
||||
"default_value_fn for '{}' produced invalid JSON: {json}",
|
||||
entry.storage_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_schemas_are_serializable() {
|
||||
let mut schema_gen = SchemaGenerator::default();
|
||||
for entry in entries() {
|
||||
let schema = (entry.schema_fn)(&mut schema_gen);
|
||||
assert!(
|
||||
serde_json::to_string(&schema).is_ok(),
|
||||
"schema_fn for '{}' produced a non-serializable schema",
|
||||
entry.storage_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duplicate_storage_keys() {
|
||||
// NOTE: when run in the settings crate alone, test modules may
|
||||
// register fixture settings with overlapping keys (e.g. both
|
||||
// mod_tests and macros_tests define "SimpleSetting"). This test
|
||||
// is most useful when run from the app crate where real settings
|
||||
// are registered. We still check for duplicates but collect them
|
||||
// all before reporting, to give a clear picture.
|
||||
let mut seen = HashSet::new();
|
||||
let mut duplicates = Vec::new();
|
||||
for entry in entries() {
|
||||
if !seen.insert(entry.storage_key) {
|
||||
duplicates.push(entry.storage_key);
|
||||
}
|
||||
}
|
||||
// In the app crate context (no test-only fixtures), there should
|
||||
// be zero duplicates.
|
||||
#[cfg(not(test))]
|
||||
assert!(
|
||||
duplicates.is_empty(),
|
||||
"Duplicate storage_keys: {duplicates:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_supported_platforms_fn_succeed() {
|
||||
for entry in entries() {
|
||||
// Just call it to ensure it doesn't panic.
|
||||
let _ = (entry.supported_platforms_fn)();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_values_are_well_formed() {
|
||||
for entry in entries() {
|
||||
if let Some(h) = entry.hierarchy {
|
||||
assert!(
|
||||
!h.starts_with('.') && !h.ends_with('.'),
|
||||
"Hierarchy for '{}' has leading/trailing dots: '{h}'",
|
||||
entry.storage_key
|
||||
);
|
||||
assert!(
|
||||
!h.contains(".."),
|
||||
"Hierarchy for '{}' contains consecutive dots: '{h}'",
|
||||
entry.storage_key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// $ref resolution test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn all_refs_resolve_to_definitions() {
|
||||
let mut schema_gen = SchemaGenerator::default();
|
||||
|
||||
// Process all entries through the shared generator
|
||||
for entry in entries() {
|
||||
let _schema = (entry.schema_fn)(&mut schema_gen);
|
||||
}
|
||||
|
||||
// Collect all $ref pointers from the schemas
|
||||
fn collect_refs(value: &serde_json::Value, refs: &mut HashSet<String>) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
if let Some(serde_json::Value::String(r)) = map.get("$ref") {
|
||||
refs.insert(r.clone());
|
||||
}
|
||||
for v in map.values() {
|
||||
collect_refs(v, refs);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for v in arr {
|
||||
collect_refs(v, refs);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-process to collect refs from the output
|
||||
let mut schema_gen2 = SchemaGenerator::default();
|
||||
let mut all_refs = HashSet::new();
|
||||
for entry in entries() {
|
||||
let schema = (entry.schema_fn)(&mut schema_gen2);
|
||||
let value = serde_json::to_value(&schema).unwrap();
|
||||
collect_refs(&value, &mut all_refs);
|
||||
}
|
||||
|
||||
let defs = schema_gen2.definitions();
|
||||
for r in &all_refs {
|
||||
// schemars 1.x uses "#/$defs/TypeName"
|
||||
if let Some(type_name) = r.strip_prefix("#/$defs/") {
|
||||
assert!(
|
||||
defs.contains_key(type_name),
|
||||
"$ref '{r}' does not resolve to any definition. Available: {:?}",
|
||||
defs.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type schema validation (using fixture settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Helper to generate a schema for a type and return it as a JSON value.
|
||||
fn schema_value_for<T: JsonSchema>() -> serde_json::Value {
|
||||
let mut schema_gen = SchemaGenerator::default();
|
||||
let schema = T::json_schema(&mut schema_gen);
|
||||
schema.to_value()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_schema() {
|
||||
let v = schema_value_for::<bool>();
|
||||
assert_eq!(v.get("type").and_then(|t| t.as_str()), Some("boolean"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_schema() {
|
||||
let v = schema_value_for::<String>();
|
||||
assert_eq!(v.get("type").and_then(|t| t.as_str()), Some("string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_enum_schema() {
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
enum SimpleEnum {
|
||||
A,
|
||||
B,
|
||||
C,
|
||||
}
|
||||
|
||||
let v = schema_value_for::<SimpleEnum>();
|
||||
let variants = v.get("enum").and_then(|e| e.as_array());
|
||||
assert!(variants.is_some(), "Expected enum array in schema");
|
||||
let names: Vec<&str> = variants
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["A", "B", "C"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_field_schema() {
|
||||
let v = schema_value_for::<Option<String>>();
|
||||
// schemars 0.8 may represent Option<T> as:
|
||||
// - {"anyOf": [...]}
|
||||
// - {"type": ["string", "null"]}
|
||||
let has_any_of = v.get("anyOf").is_some();
|
||||
let has_one_of = v.get("oneOf").is_some();
|
||||
let has_nullable_type = v
|
||||
.get("type")
|
||||
.and_then(|t| t.as_array())
|
||||
.is_some_and(|arr| arr.iter().any(|t| t.as_str() == Some("null")));
|
||||
assert!(
|
||||
has_any_of || has_one_of || has_nullable_type,
|
||||
"Expected anyOf, oneOf, or nullable type array for Option<String>, got: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_schema() {
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct MyStruct {
|
||||
name: String,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
let v = schema_value_for::<MyStruct>();
|
||||
assert_eq!(v.get("type").and_then(|t| t.as_str()), Some("object"));
|
||||
let props = v.get("properties").and_then(|p| p.as_object());
|
||||
assert!(props.is_some(), "Expected properties in struct schema");
|
||||
let props = props.unwrap();
|
||||
assert!(props.contains_key("name"));
|
||||
assert!(props.contains_key("count"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_struct_ref_resolves() {
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct Inner {
|
||||
value: i32,
|
||||
}
|
||||
|
||||
#[derive(JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct Outer {
|
||||
inner: Inner,
|
||||
}
|
||||
|
||||
let mut schema_gen = SchemaGenerator::default();
|
||||
let schema = Outer::json_schema(&mut schema_gen);
|
||||
let v = serde_json::to_value(schema).unwrap();
|
||||
|
||||
// The inner field should be a $ref
|
||||
let inner_prop = v
|
||||
.pointer("/properties/inner")
|
||||
.expect("Expected inner property");
|
||||
|
||||
let has_ref = inner_prop.get("$ref").is_some() || inner_prop.get("allOf").is_some();
|
||||
assert!(
|
||||
has_ref,
|
||||
"Expected $ref or allOf for nested struct, got: {inner_prop}"
|
||||
);
|
||||
|
||||
// The definition should exist
|
||||
let defs = schema_gen.definitions();
|
||||
assert!(
|
||||
defs.contains_key("Inner"),
|
||||
"Expected 'Inner' in definitions"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn storage_key_single_segment() {
|
||||
assert_eq!(toml_path_storage_key("font_name"), "font_name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_key_two_segments() {
|
||||
assert_eq!(toml_path_storage_key("font.font_name"), "font_name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_key_three_segments() {
|
||||
assert_eq!(
|
||||
toml_path_storage_key("appearance.text.font_name"),
|
||||
"font_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_single_segment() {
|
||||
assert_eq!(toml_path_hierarchy("font_name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_two_segments() {
|
||||
assert_eq!(toml_path_hierarchy("font.font_name"), Some("font"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_three_segments() {
|
||||
assert_eq!(
|
||||
toml_path_hierarchy("appearance.text.font_name"),
|
||||
Some("appearance.text")
|
||||
);
|
||||
}
|
||||
|
||||
// Verify const evaluation works at compile time.
|
||||
const _: () = {
|
||||
assert!(matches!(toml_path_storage_key("a.b.c").as_bytes(), b"c"));
|
||||
assert!(matches!(toml_path_storage_key("key").as_bytes(), b"key"));
|
||||
};
|
||||
Reference in New Issue
Block a user