296 lines
9.4 KiB
Rust
296 lines
9.4 KiB
Rust
//! Defines the [`SettingsValue`] trait for controlling how setting value
|
|
//! types are serialized to and deserialized from the user-visible TOML settings
|
|
//! file.
|
|
//!
|
|
//! The trait provides a parallel serialization path to serde: types implement
|
|
//! `to_file_value` / `from_file_value` to produce a human-friendly JSON
|
|
//! representation that the TOML backend converts to TOML. The default
|
|
//! implementation delegates to serde, so types without custom file formatting
|
|
//! need only an empty `impl SettingsValue for T {}`.
|
|
//!
|
|
//! Cloud sync and platform-native stores (UserDefaults, registry) continue
|
|
//! using serde directly — this trait is only consulted when writing to or
|
|
//! reading from the settings file.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::hash::Hash;
|
|
use std::path::PathBuf;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use instant::Duration;
|
|
use serde::{de::DeserializeOwned, Serialize};
|
|
|
|
// Re-export the derive macro when available.
|
|
use serde_json::Value;
|
|
#[cfg(feature = "derive")]
|
|
pub use settings_value_derive::SettingsValue;
|
|
|
|
/// Defines how a type is represented in the user-visible settings file.
|
|
///
|
|
/// The default implementation delegates to serde (`serde_json::to_value` /
|
|
/// `serde_json::from_value`), so types that don't need a custom file
|
|
/// representation can use an empty impl:
|
|
///
|
|
/// ```ignore
|
|
/// impl SettingsValue for MyType {}
|
|
/// ```
|
|
///
|
|
/// Types that want a different representation override the methods. For
|
|
/// example, `Duration` serializes as an integer (seconds) rather than the
|
|
/// serde `{ secs, nanos }` object.
|
|
///
|
|
/// # Choosing an implementation strategy
|
|
///
|
|
/// **`#[derive(SettingsValue)]`** — the default choice for most types.
|
|
/// For enums, the derive converts variant names to snake_case and
|
|
/// recursively serializes inner data via `SettingsValue`. For structs,
|
|
/// it recursively calls `to_file_value`/`from_file_value` on each field.
|
|
/// The derive **bypasses serde entirely** — it does not call
|
|
/// `serde_json::to_value`.
|
|
///
|
|
/// **Empty `impl SettingsValue for T {}`** (serde passthrough) — use when
|
|
/// the type has custom `Serialize`/`Deserialize` impls that already
|
|
/// produce the desired file format (e.g. `StartupShell` serializes as
|
|
/// `Option<String>`, `SyncId` flattens to a plain string). The
|
|
/// passthrough delegates to serde, so those custom impls are respected.
|
|
/// Also use this for types in external crates where you cannot add a
|
|
/// derive attribute (orphan rule).
|
|
///
|
|
/// **Manual impl with overridden methods** — use when neither the derive
|
|
/// nor the serde output is suitable. For example,
|
|
/// `AgentModeCommandExecutionPredicate` serializes as a plain regex
|
|
/// string in the file, which neither the derive nor serde would produce.
|
|
pub trait SettingsValue: Serialize + DeserializeOwned {
|
|
/// Converts this value to a JSON representation for the settings file.
|
|
fn to_file_value(&self) -> Value {
|
|
serde_json::to_value(self).expect("serializable type should convert to Value")
|
|
}
|
|
|
|
/// Reconstructs this value from a JSON representation read from the
|
|
/// settings file. Returns `None` if the value cannot be parsed.
|
|
fn from_file_value(value: &Value) -> Option<Self>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
serde_json::from_value(value.clone()).ok()
|
|
}
|
|
|
|
/// Returns the JSON Schema describing the file representation of this type.
|
|
///
|
|
/// The default delegates to `schemars::JsonSchema`, which is correct for
|
|
/// passthrough types. Override when `to_file_value` produces a different
|
|
/// shape than serde (e.g. Duration → integer seconds).
|
|
fn file_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema
|
|
where
|
|
Self: schemars::JsonSchema,
|
|
{
|
|
gen.subschema_for::<Self>()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Convenience macro for external types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Implements `SettingsValue` as a serde passthrough for types outside this
|
|
/// crate. The listed types should have appropriate `#[serde(rename_all = …)]`
|
|
/// attributes on their definitions to ensure the desired serialization format
|
|
/// in the settings file.
|
|
#[macro_export]
|
|
macro_rules! impl_snake_case {
|
|
($($ty:ty),* $(,)?) => {
|
|
$(impl $crate::SettingsValue for $ty {})*
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Primitive impls (serde passthrough)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
macro_rules! impl_default_file_format {
|
|
($($ty:ty),* $(,)?) => {
|
|
$(impl SettingsValue for $ty {})*
|
|
};
|
|
}
|
|
|
|
impl_default_file_format!(
|
|
bool,
|
|
u8,
|
|
u16,
|
|
u32,
|
|
u64,
|
|
usize,
|
|
i8,
|
|
i16,
|
|
i32,
|
|
i64,
|
|
f32,
|
|
f64,
|
|
String,
|
|
PathBuf,
|
|
DateTime<Utc>,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generic collection impls (recursive)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl<T: SettingsValue> SettingsValue for Vec<T> {
|
|
fn to_file_value(&self) -> Value {
|
|
Value::Array(self.iter().map(T::to_file_value).collect())
|
|
}
|
|
|
|
fn from_file_value(value: &Value) -> Option<Self> {
|
|
value.as_array()?.iter().map(T::from_file_value).collect()
|
|
}
|
|
}
|
|
|
|
impl<T: SettingsValue> SettingsValue for Option<T> {
|
|
fn to_file_value(&self) -> Value {
|
|
match self {
|
|
Some(v) => v.to_file_value(),
|
|
None => Value::Null,
|
|
}
|
|
}
|
|
|
|
fn from_file_value(value: &Value) -> Option<Self> {
|
|
if value.is_null() {
|
|
Some(None)
|
|
} else {
|
|
Some(Some(T::from_file_value(value)?))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T> SettingsValue for HashSet<T>
|
|
where
|
|
T: SettingsValue + Eq + Hash,
|
|
{
|
|
fn to_file_value(&self) -> Value {
|
|
Value::Array(self.iter().map(T::to_file_value).collect())
|
|
}
|
|
|
|
fn from_file_value(value: &Value) -> Option<Self> {
|
|
value.as_array()?.iter().map(T::from_file_value).collect()
|
|
}
|
|
}
|
|
|
|
impl<K, V> SettingsValue for HashMap<K, V>
|
|
where
|
|
K: SettingsValue + Eq + Hash,
|
|
V: SettingsValue,
|
|
{
|
|
fn to_file_value(&self) -> Value {
|
|
let mut obj = serde_json::Map::new();
|
|
for (k, v) in self {
|
|
let key_str = match k.to_file_value() {
|
|
Value::String(s) => s,
|
|
other => other.to_string(),
|
|
};
|
|
obj.insert(key_str, v.to_file_value());
|
|
}
|
|
Value::Object(obj)
|
|
}
|
|
|
|
fn from_file_value(value: &Value) -> Option<Self> {
|
|
let obj = value.as_object()?;
|
|
let mut map = HashMap::new();
|
|
for (key_str, val) in obj {
|
|
let k = K::from_file_value(&Value::String(key_str.clone()))?;
|
|
let v = V::from_file_value(val)?;
|
|
map.insert(k, v);
|
|
}
|
|
Some(map)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Duration — serialize as integer seconds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
impl SettingsValue for Duration {
|
|
fn to_file_value(&self) -> Value {
|
|
Value::Number(self.as_secs().into())
|
|
}
|
|
|
|
fn from_file_value(value: &Value) -> Option<Self> {
|
|
value.as_u64().map(Duration::from_secs)
|
|
}
|
|
|
|
fn file_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema
|
|
where
|
|
Self: schemars::JsonSchema,
|
|
{
|
|
gen.subschema_for::<u64>()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn duration_round_trip() {
|
|
let d = Duration::from_secs(30);
|
|
let file_val = d.to_file_value();
|
|
assert_eq!(file_val, Value::Number(30.into()));
|
|
let back = Duration::from_file_value(&file_val).unwrap();
|
|
assert_eq!(back, d);
|
|
}
|
|
|
|
#[test]
|
|
fn vec_recursive() {
|
|
let v = vec![10u32, 20u32];
|
|
let file_val = v.to_file_value();
|
|
assert_eq!(
|
|
file_val,
|
|
Value::Array(vec![Value::Number(10.into()), Value::Number(20.into())])
|
|
);
|
|
let back = Vec::<u32>::from_file_value(&file_val).unwrap();
|
|
assert_eq!(back, v);
|
|
}
|
|
|
|
#[test]
|
|
fn option_some() {
|
|
let v: Option<u32> = Some(5);
|
|
let file_val = v.to_file_value();
|
|
assert_eq!(file_val, Value::Number(5.into()));
|
|
let back = Option::<u32>::from_file_value(&file_val).unwrap();
|
|
assert_eq!(back, v);
|
|
}
|
|
|
|
#[test]
|
|
fn option_none() {
|
|
let v: Option<u32> = None;
|
|
let file_val = v.to_file_value();
|
|
assert_eq!(file_val, Value::Null);
|
|
let back = Option::<u32>::from_file_value(&file_val).unwrap();
|
|
assert_eq!(back, v);
|
|
}
|
|
|
|
#[test]
|
|
fn bool_passthrough() {
|
|
assert_eq!(true.to_file_value(), Value::Bool(true));
|
|
assert_eq!(bool::from_file_value(&Value::Bool(false)), Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn string_passthrough() {
|
|
let s = "hello".to_string();
|
|
let file_val = s.to_file_value();
|
|
assert_eq!(file_val, Value::String("hello".into()));
|
|
assert_eq!(String::from_file_value(&file_val), Some(s));
|
|
}
|
|
|
|
#[test]
|
|
fn hashmap_round_trip() {
|
|
let mut m = HashMap::new();
|
|
m.insert("key".to_string(), 42u32);
|
|
let file_val = m.to_file_value();
|
|
let obj = file_val.as_object().unwrap();
|
|
assert_eq!(obj.get("key"), Some(&Value::Number(42.into())));
|
|
let back = HashMap::<String, u32>::from_file_value(&file_val).unwrap();
|
|
assert_eq!(back, m);
|
|
}
|
|
}
|