Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 59 additions & 42 deletions apps/native/src-tauri/configurable-derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
.display_name
.clone()
.unwrap_or_else(|| name_str.clone());
let fields = generate_fields(named_fields(&input)?, &name_str)?;
let fields = generate_fields(named_fields(&input)?)?;
let methods = build_scope_methods(
name,
&name_str,
Expand All @@ -45,23 +45,39 @@ pub(crate) fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
impl #name {
#methods

// Wry-specialized shims for the type-erased registry. Generic
// functions can't be cast to fn pointers; these monomorphic
// Wry-specialized shims for the type-erased compile-time registry.
// Generic functions can't be cast to fn pointers; these monomorphic
// wrappers can.
#[doc(hidden)]
pub fn __configurable_schema_wry(
pub fn __configurable_schema_wry() -> ::configurable::ConfigurableSchema {
Self::schema()
}

#[doc(hidden)]
pub fn __configurable_load_wry(
app: &::tauri::AppHandle<::tauri::Wry>,
) -> ::std::result::Result<::configurable::ConfigurableSchema, ::anyhow::Error> {
Self::schema(app)
) -> ::std::result::Result<::serde_json::Value, ::anyhow::Error> {
let __current = Self::load(app)?;
::std::result::Result::Ok(::serde_json::to_value(&__current)?)
}

#[doc(hidden)]
pub fn __configurable_set_field_wry(
pub fn __configurable_set_wry(
app: &::tauri::AppHandle<::tauri::Wry>,
key: &str,
value: ::serde_json::Value,
) -> ::std::result::Result<(), ::anyhow::Error> {
Self::set_field(app, key, value)
Self::set(app, value)
}
}

// Push this configurable into the inventory collection so
// `inventory::iter::<ConfigurableMeta>()` enumerates it at runtime.
::configurable::inventory::submit! {
::configurable::ConfigurableMeta {
name: #name_str,
schema_fn: #name::__configurable_schema_wry,
load_fn: #name::__configurable_load_wry,
set_fn: #name::__configurable_set_wry,
}
}
})
Expand All @@ -78,11 +94,16 @@ fn description_expr(description: Option<&String>) -> TokenStream2 {
}
}

/// Emits the generated `load`, `schema`, and `set_field` methods.
/// Emits the generated `load`, `schema`, and `set` methods.
///
/// All generated methods target the managed `Slice<T>` for the derived type.
/// That keeps persistence and UI change events behind the slice guard instead
/// of letting each configurable type choose its own storage path.
/// `schema` is intentionally context-free: it returns a `ConfigurableSchema`
/// built from the derive attributes alone, with no `AppHandle` involved. The
/// dev-settings command joins it with current values at the IPC boundary via
/// `load_value` so the static metadata stays cacheable.
///
/// `set` takes the whole struct as a single JSON payload. One Serde
/// deserialize validates every field at once; there is no per-field dispatch
/// table because the type system already knows the shape of `Self`.
fn build_scope_methods(
name: &syn::Ident,
name_str: &str,
Expand All @@ -97,59 +118,55 @@ fn build_scope_methods(
};
let default_inits = fields.default_inits;
let schema_fields = fields.schema_fields;
let set_field_arms = fields.set_field_arms;

// The derive is slice-only: reads mirror the managed slice, and writes go
// through the slice guard so persistence and change events stay centralized.
// The derive is observable-only: reads mirror the managed observable, and
// writes go through the observable guard so persistence and change events
// stay centralized.
//
// `load` can be called before startup finishes managing all slices, such as
// in tests or early schema rendering. Defaults keep that path deterministic.
// `load` can be called before startup finishes managing all observables,
// such as in tests or early schema rendering. Defaults keep that path
// deterministic.
quote! {
pub fn load<R: ::tauri::Runtime>(
app: &::tauri::AppHandle<R>,
) -> ::std::result::Result<Self, ::anyhow::Error> {
if let ::std::option::Option::Some(__slice) =
::tauri::Manager::try_state::<crate::state::slice::Slice<#name>>(app)
if let ::std::option::Option::Some(__observable) =
::tauri::Manager::try_state::<crate::observable::Observable<#name>>(app)
{
return ::std::result::Result::Ok(__slice.read_sync().clone());
return ::std::result::Result::Ok(__observable.read_sync().clone());
}
::std::result::Result::Ok(Self {
#(#default_inits)*
})
}

pub fn schema<R: ::tauri::Runtime>(
app: &::tauri::AppHandle<R>,
) -> ::std::result::Result<::configurable::ConfigurableSchema, ::anyhow::Error> {
let __current = Self::load(app)?;
::std::result::Result::Ok(::configurable::ConfigurableSchema {
pub fn schema() -> ::configurable::ConfigurableSchema {
::configurable::ConfigurableSchema {
name: #name_str.to_string(),
display_name: #display_name.to_string(),
description: #description_expr,
fields: ::std::vec![
#(#schema_fields)*
],
})
}
}

pub fn set_field<R: ::tauri::Runtime>(
pub fn set<R: ::tauri::Runtime>(
app: &::tauri::AppHandle<R>,
key: &str,
value: ::serde_json::Value,
) -> ::std::result::Result<(), ::anyhow::Error> {
let __slice = ::tauri::Manager::try_state::<crate::state::slice::Slice<#name>>(app)
.ok_or_else(|| ::anyhow::anyhow!(
"Configurable {}: {} slice is not managed",
#name_str, #scope_name,
))?;
let mut __state = __slice.write_sync(app);
match key {
#(#set_field_arms)*
other => ::std::result::Result::Err(::anyhow::anyhow!(
"Configurable {}: unknown field `{}`",
#name_str, other,
)),
}
let __observable =
::tauri::Manager::try_state::<crate::observable::Observable<#name>>(app)
.ok_or_else(|| ::anyhow::anyhow!(
"Configurable {}: {} observable is not managed",
#name_str, #scope_name,
))?;
let __next: Self = ::serde_json::from_value(value).map_err(|e| {
::anyhow::anyhow!("Configurable {}: invalid payload: {}", #name_str, e)
})?;
let mut __state = __observable.write_sync();
*__state = __next;
::std::result::Result::Ok(())
}
}
}
39 changes: 11 additions & 28 deletions apps/native/src-tauri/configurable-derive/src/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,15 @@ use syn::{Data, DeriveInput, Fields};
pub(crate) struct FieldCode {
default_init: TokenStream2,
schema_field: TokenStream2,
set_field_arm: TokenStream2,
}

/// The field-level fragments are consumed by different generated methods:
/// defaults feed `load`, schema entries feed `schema`, and setter arms feed
/// `set_field`.
/// defaults feed `load`, schema entries feed `schema`. The whole-struct
/// `set` path doesn't need per-field fragments — Serde validates every
/// field at once.
pub(crate) struct GeneratedFields {
pub(crate) default_inits: Vec<TokenStream2>,
pub(crate) schema_fields: Vec<TokenStream2>,
pub(crate) set_field_arms: Vec<TokenStream2>,
}

/// Returns the named fields the derive knows how to expose as settings.
Expand Down Expand Up @@ -56,37 +55,34 @@ pub(crate) fn named_fields(

/// Generates all field-level fragments in one pass.
///
/// Each configurable field contributes to three generated methods. Grouping
/// Defaults feed `load`'s fallback; schema entries feed `schema`. Grouping
/// fragments by method keeps `build_scope_methods` simple without reparsing
/// field attributes later.
pub(crate) fn generate_fields(
fields: &syn::punctuated::Punctuated<syn::Field, syn::token::Comma>,
name_str: &str,
) -> syn::Result<GeneratedFields> {
let mut default_inits = Vec::new();
let mut schema_fields = Vec::new();
let mut set_field_arms = Vec::new();

for field in fields {
let generated = generate_field(field, name_str)?;
let generated = generate_field(field)?;
default_inits.push(generated.default_init);
schema_fields.push(generated.schema_field);
set_field_arms.push(generated.set_field_arm);
}

Ok(GeneratedFields {
default_inits,
schema_fields,
set_field_arms,
})
}

/// Validates one field and emits the snippets needed by the generated methods.
///
/// This is where field attributes become concrete behavior: default values feed
/// fallback loading, type metadata feeds the UI schema, and the setter arm
/// round-trips through the declared Rust type before mutating the slice.
fn generate_field(field: &syn::Field, name_str: &str) -> syn::Result<FieldCode> {
/// This is where field attributes become concrete behavior: default values
/// feed fallback loading and type metadata feeds the UI schema. Setting is
/// handled at the struct level by Serde, so no per-field setter code is
/// emitted here.
fn generate_field(field: &syn::Field) -> syn::Result<FieldCode> {
let ident = field
.ident
.as_ref()
Expand Down Expand Up @@ -117,26 +113,13 @@ fn generate_field(field: &syn::Field, name_str: &str) -> syn::Result<FieldCode>
#ident: #default,
},
schema_field: quote! {
::configurable::ConfigField {
::configurable::ConfigFieldSchema {
key: #key.to_string(),
label: #label.to_string(),
help: #help_expr,
ty: #ty_expr,
default: ::serde_json::json!(#default),
current: ::serde_json::to_value(&__current.#ident)
.unwrap_or_else(|_| ::serde_json::json!(#default)),
},
},
set_field_arm: quote! {
#key => {
let __typed: #ty = ::serde_json::from_value(value.clone())
.map_err(|e| ::anyhow::anyhow!(
"Configurable {}: invalid value for `{}`: {}",
#name_str, #key, e,
))?;
__state.#ident = __typed;
::std::result::Result::Ok(())
}
},
})
}
1 change: 1 addition & 0 deletions apps/native/src-tauri/configurable/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ license = "MIT"

[dependencies]
anyhow = "1"
inventory = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] }
Expand Down
61 changes: 47 additions & 14 deletions apps/native/src-tauri/configurable/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
//! returns to settings/dev tooling.
//!
//! Derive `Configurable` on a struct to:
//! 1. Generate a `load(app)` method that reads the managed slice, falling
//! back to per-field defaults when the slice is not yet registered.
//! 2. Expose a rich schema (`schema()`) describing every field's
//! type, label, help text, range, and default value.
//! 3. Generate Wry-specialized shim methods that callers can register with
//! the slice registry.
//! 1. Generate a `load(app)` method that reads the managed observable,
//! falling back to per-field defaults when it isn't yet managed.
//! 2. Expose a static schema (`schema()`) describing every field's type,
//! label, help text, range, and default value. No `AppHandle` needed —
//! the schema is the same value every call and trivially cacheable.
//! 3. Push the type into a compile-time `inventory` collection so the dev
//! settings UI can enumerate every configurable without explicit
//! registration.
//!
//! ```ignore
//! use configurable::Configurable;
Expand All @@ -32,14 +34,18 @@
//! }
//!
//! let limits = EvolutionLimits::load(&app)?;
//! let schema = EvolutionLimits::schema(&app)?;
//! let schema = EvolutionLimits::schema();
//! ```

use serde::{Deserialize, Serialize};
use specta::Type;

pub use configurable_derive::Configurable;

// Re-exported so derive output can write `::configurable::inventory::submit!`
// without consumers needing to add the crate themselves.
pub use inventory;

// =============================================================================
// Schema types — flow to TS via specta
// =============================================================================
Expand Down Expand Up @@ -72,10 +78,13 @@ pub struct EnumVariant {
pub label: String,
}

/// Per-field description rendered into a UI control.
/// Static description of one Configurable field.
///
/// Produced by the derive macro with no runtime context; the same value every
/// call. Joined with the current store-backed value by `key` at render time.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ConfigField {
pub struct ConfigFieldSchema {
/// Key as written to the underlying store (typically camelCase).
pub key: String,
/// Human-readable label rendered above the input.
Expand All @@ -87,22 +96,46 @@ pub struct ConfigField {
pub ty: FieldType,
/// Default if the store has no value yet.
pub default: serde_json::Value,
/// Current value loaded from the store.
pub current: serde_json::Value,
}

/// One section in the auto-rendered settings panel — corresponds to one
/// `#[derive(Configurable)]` struct.
/// `#[derive(Configurable)]` struct. Static metadata only; current values are
/// fetched separately and joined by struct name + field key on the frontend.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ConfigurableSchema {
/// Unique stable identifier (struct's Rust name). Used by `set_field` to
/// Unique stable identifier (struct's Rust name). Used by the setter to
/// dispatch to the right registered configurable.
pub name: String,
/// Title shown above the section in the UI.
pub display_name: String,
/// Optional one-line description shown under the title.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub fields: Vec<ConfigField>,
pub fields: Vec<ConfigFieldSchema>,
}

// =============================================================================
// Compile-time registry — populated by the derive via `inventory::submit!`
// =============================================================================

/// Static metadata for one `#[derive(Configurable)]` struct.
///
/// The derive macro pushes one of these per struct via `inventory::submit!`
/// at link time, so iterating every registered configurable is just a walk
/// over `inventory::iter::<ConfigurableMeta>()` — no runtime registry, no
/// app-startup registration step.
pub struct ConfigurableMeta {
/// Stable Rust-side name of the configurable state type.
pub name: &'static str,
/// Returns the static UI schema. Same value every call; no app needed.
pub schema_fn: fn() -> ConfigurableSchema,
/// Loads the current state as a JSON object so the dev-settings command
/// can join it with the static schema by field key.
pub load_fn: fn(&tauri::AppHandle<tauri::Wry>) -> anyhow::Result<serde_json::Value>,
/// Replaces the managed observable's value with a deserialized
/// whole-struct payload. Serde validates every field in one pass.
pub set_fn: fn(&tauri::AppHandle<tauri::Wry>, serde_json::Value) -> anyhow::Result<()>,
}

inventory::collect!(ConfigurableMeta);
Loading
Loading