# 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
