attune
Runtime-mutable, persisted, observable configuration for Rust.
attune is for long-lived apps that need more than static config loading. It
loads settings from environment variables and TOML, lets selected fields be
changed at runtime, persists those changes to SQLite, and emits change events
inside the process and across processes.
The v0.1 focus is desktop apps, companion CLIs, daemons, and other processes where config can be changed while the app is running.
Quick Start
use ;
AppConfig::load() returns a generated handle wrapper. The wrapper dereferences
to the core SettingsHandle, so generic methods like snapshot() and
on_change() are available, and generated field methods like theme() expose
typed field handles.
Persisted Fields
Fields are read-only by default. Add persist when a field should be mutable at
runtime and stored in SQLite:
Persisted field handles expose:
get()for the current typed value.set(value)to persist a new value and update the in-memory snapshot.on_change()to receive typed local and externalSetevents for that field.
Read-only field handles expose only get().
Source Precedence
Read-only fields and persisted fields intentionally use different precedence rules.
flowchart TD
Start(["load field"])
Persist{"persist?"}
Stored{"stored DB row exists?"}
Env{"env var set?"}
Toml{"TOML key exists?"}
UseStored["use stored DB value"]
UseEnv["parse env value"]
UseToml["deserialize TOML value"]
UseDefault["use attribute default"]
Start --> Persist
Persist -->|yes| Stored
Persist -->|no| Env
Stored -->|yes| UseStored
Stored -->|no| Env
Env -->|yes| UseEnv
Env -->|no| Toml
Toml -->|yes| UseToml
Toml -->|no| UseDefault
Read-only fields use:
environment > TOML > default
Persisted fields use:
stored DB value > environment/TOML/default seed
The seed is in memory only. attune does not create a DB row for a persisted
field until the field is written with field().set(...).
Once a persisted field has a stored DB row, that row is authoritative across restarts. Environment and TOML changes for that field are ignored until the row is deleted or migrated.
Config and Database Paths
Use struct-level attributes to configure paths:
Path behavior:
config_path = "..."points to an optional TOML config file.db_path = "..."points to the SQLite database used by persisted fields.app = "..."names the app for env overrides and default DB path discovery.<APP>_CONFIGoverrides the configured path when set.
If config_path is absent, no TOML file is loaded. If db_path is absent and
the struct has persisted fields, attune resolves settings.db under the
platform config directory using the directories crate.
For local development, set XDG_CONFIG_HOME to keep generated files inside the
project:
XDG_CONFIG_HOME=./.dev-config
SQLite Defaults
The default SQLite backend is configured for ordinary app settings workloads:
journal_mode = WALbusy_timeout = 5000mssynchronous = NORMALcross_process = truecross_process_poll_ms = 1000
Override these with struct-level attributes:
Cross-Process Changes
When cross-process detection is enabled, the SQLite backend polls
PRAGMA data_version. If another process writes to the same database, attune
reloads stored values, applies known persisted keys to the in-memory snapshot,
and emits ChangeSource::External events.
flowchart LR
User["user code"]
Generated["generated AppConfigHandle"]
Field["typed field handles"]
Core["SettingsHandle"]
Backend["SqliteBackend"]
DB[("SQLite settings table")]
Watcher["data_version watcher"]
Subscribers["change subscribers"]
User --> Generated
Generated --> Field
Generated --> Core
Field --> Core
Core --> Backend
Backend --> DB
Watcher --> Backend
DB -. external commit .-> Watcher
Core --> Subscribers
Local and external writes follow the same event model, but they enter the system from different places:
sequenceDiagram
participant App as This process
participant Handle as SettingsHandle
participant DB as SQLite
participant Other as Other process
participant Watcher as Watcher
participant Subs as Subscribers
App->>Handle: theme().set("dark")
Handle->>DB: persist value
Handle->>Handle: update snapshot
Handle->>Subs: ChangeSource::Local
Other->>DB: write theme = "light"
Watcher->>DB: detect data_version change
Watcher->>Handle: reload and diff
Handle->>Handle: apply generated field update
Handle->>Subs: ChangeSource::External
If an external value cannot be decoded as the current Rust field type, attune
emits ChangeEvent::DeserializeFailure on the generic on_change() stream and
keeps the current in-memory value. Typed per-field subscribers ignore failed
decodes.
Deserialize Fallbacks
Use deserialize_fallback to migrate legacy persisted values that no longer
match the current field type:
use Settings;
Normal persisted JSON decode is attempted first. The fallback runs only if that decode fails.
Errors
SettingsError and BackendError are structured and matchable. Both are marked
#[non_exhaustive], so downstream matches should include a wildcard arm.
use ;
v0.1 Scope
Included in v0.1:
#[derive(Settings)]- Environment variables, TOML, and attribute defaults
- SQLite-backed persisted fields
- Local and cross-process change events
- Typed field handles
- Matchable error types
Deferred until after v0.1:
- Postgres and MySQL backends
- Async API
- JSON/YAML config files
- Audit logs
- Rich validation hooks