attune 0.1.0

Runtime-mutable, persisted, observable configuration for Rust.
Documentation

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 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:

#[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.

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:

#[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:

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:

#[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.

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 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.

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