Files
galaxy/crates/settings_value/src/lib.rs
T

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);
}
}