# 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
```rust
use attune::{Settings, SettingsError};
#[derive(Clone, Settings)]
#[settings(app = "my_app", config_path = "config.toml", db_path = "settings.db")]
struct AppConfig {
#[setting(env = "APP_PORT", default = 8080)]
port: u16,
#[setting(default = "info", key = "log.level")]
log_level: String,
#[setting(persist, default = "system", key = "theme")]
theme: String,
}
fn main() -> Result<(), SettingsError> {
let settings = AppConfig::load()?;
let port = settings.port().get();
let theme = settings.theme().get();
settings.theme().set("dark".to_string())?;
println!("listening on {port} with {theme} theme");
Ok(())
}
```
`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:
```rust
#[derive(Clone, Settings)]
#[settings(app = "my_app")]
struct AppConfig {
#[setting(persist, default = "system", key = "theme")]
theme: String,
}
fn update_theme() -> Result<(), attune::SettingsError> {
let settings = AppConfig::load()?;
assert_eq!(settings.theme().get(), "system");
settings.theme().set("dark".to_string())?;
let rx = settings.theme().on_change();
settings.theme().set("light".to_string())?;
assert_eq!(rx.recv().unwrap(), "light");
Ok(())
}
```
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 external `Set` events for that field.
Read-only field handles expose only `get()`.
## Source Precedence
Read-only fields and persisted fields intentionally use different precedence
rules.
```mermaid
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:
```text
environment > TOML > default
```
Persisted fields use:
```text
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:
```rust
#[derive(Clone, attune::Settings)]
#[settings(app = "my_app", config_path = "config.toml", db_path = "settings.db")]
struct AppConfig {
#[setting(default = 8080)]
port: u16,
}
```
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>_CONFIG` overrides 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:
```bash
XDG_CONFIG_HOME=./.dev-config cargo run
```
## SQLite Defaults
The default SQLite backend is configured for ordinary app settings workloads:
- `journal_mode = WAL`
- `busy_timeout = 5000ms`
- `synchronous = NORMAL`
- `cross_process = true`
- `cross_process_poll_ms = 1000`
Override these with struct-level attributes:
```rust
#[derive(Clone, attune::Settings)]
#[settings(
app = "my_app",
cross_process = true,
cross_process_poll_ms = 250,
sqlite_journal = "WAL",
sqlite_busy_ms = 5000,
sqlite_synchronous = "NORMAL"
)]
struct AppConfig {
#[setting(persist, default = "system")]
theme: String,
}
```
## 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.
```mermaid
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:
```mermaid
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:
```rust
use attune::Settings;
#[derive(Clone, Settings)]
struct AppConfig {
#[setting(persist, default = 80, deserialize_fallback = "parse_legacy_port")]
port: u16,
}
fn parse_legacy_port(raw: &str) -> Result<u16, String> {
match raw {
"\"legacy-port\"" | "legacy-port" => Ok(9000),
other => Err(format!("unknown legacy port: {other}")),
}
}
```
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.
```rust
use attune::{BackendError, Settings, SettingsError};
#[derive(Clone, Settings)]
#[settings(app = "my_app")]
struct AppConfig {
#[setting(persist, default = "system")]
theme: String,
}
fn load_settings() {
match AppConfig::load() {
Ok(settings) => {
println!("theme: {}", settings.theme().get());
}
Err(SettingsError::Backend(BackendError::Open(message))) => {
eprintln!("could not open settings database: {message}");
}
Err(SettingsError::NoConfigDir) => {
eprintln!("could not find a platform config directory");
}
Err(error) => {
eprintln!("settings error: {error}");
}
}
}
```
## 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