# settings

> An app's own settings file in ~/.config, in KConfig INI format, written atomically under a lock shared with Telamon.Ui's TelamonSettings.

The `settings` module reads and writes the app's own settings file: `$XDG_CONFIG_HOME/telamon-<app>rc`, or `~/.config/telamon-<app>rc`. The format is KConfig's INI, so KDE tools and the app's C++ and QML side read the same file. [TelamonSettings](https://telamon.eterneon.net/framework/telamon-ui/telamon-settings.md) in QML uses the same file and the same lock.

Use it only for Telamon-owned files (`telamon-<app>rc`; `atlas-<app>rc` before 2.0.0). It parses and rewrites the whole file, so do not point it at another program's configuration.

## Example

```rust
use telamon_framework_core::settings::Settings;

let app = telamon_framework_core::app_info! {
    name: "Telamon Notepad",
    id: "net.eterneon.telamon.notepad",
    repo: "atlasos-notepad",
};
let s = Settings::for_app(&app);
s.set("Restart", "ScheduledAt", Some("1700000000"))?;
assert_eq!(s.get("Restart", "ScheduledAt").as_deref(), Some("1700000000"));
s.set("Restart", "ScheduledAt", None)?; // removes the key
// (the ? operator needs a function that returns io::Result)
```

## How writes behave

- Each `set` reads the file, changes one key and replaces the file atomically (temp file, sync, rename, then a directory sync). Every other line is kept, and so are the file's mode and, when running as root, its owner.
- Writers take an `flock` on `.<name>.lock` beside the file and a thread lock. The wait for the file lock is bounded: `LOCK_NB` polling for 2 seconds, then `set` fails with `ErrorKind::TimedOut` (a stopped holder or a hung network home never freezes the caller for good). A caller must handle that error and show it, as it must any `set` error. The lock file stays on purpose: removing it would race the next writer. Concurrent `set` calls in Telamon code never lose a change. Another program writing the file without the lock can still race, but cannot corrupt it.
- `set` writes nothing, and takes no lock, when the file would not change. This works where the app can read but not write.
- A file that cannot be read is never overwritten. Reading is bounded: only a regular file of at most 4 MB is read (a FIFO is not opened for blocking, a larger file is an error), and bytes that are not UTF-8 read as U+FFFD, but such a file is never rewritten (`set` fails with `InvalidData`), so no other line or comment is changed.
- Under the lock, `set` removes the temp files (`.<name>.tmp<pid>-<n>`, exactly) left by a crashed writer when they are older than a day, once per process and file.
- A symlinked file (dotfiles) keeps its link: the target is written.
- Keys and groups that KConfig marks immutable (`[$i]`) are refused.
- Values are escaped as KConfig does: a value stays on one line (`\n`, `\t`, `\r`, `\\`, `\xNN` for other control characters), and leading or trailing spaces are written as `\s`.
- The last of duplicate keys wins when reading, as in KConfig.

## Format version

A file carries `[Telamon]` `Format=1` (`settings::FORMAT`), written when a file is first created or changed. Reading never requires it: files from before it, and files a newer app wrote with a higher number, read the same. Only a change to how existing keys are read would raise it.

## Migrations

`Settings::migrate(&[Migration])` brings an older file up to the app's schema version. The version is `[Telamon]` `SchemaVersion=N` (`SCHEMA_GROUP`, `SCHEMA_KEY`; a file nobody copied from 1.x, read through `Settings::at`, may still have it under `[Atlas]`, which counts), separate from `Format`. `migrations[n]` upgrades version `n` to `n + 1`, so the app's version is `migrations.len()`. A file without `SchemaVersion` is version 0. Call `migrate` once at start, before reading.

```rust
use telamon_framework_core::settings::{Migration, Settings, get_in, set_in};

fn rename_lang(text: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
    let v = get_in(text, "General", "Lang");
    let text = set_in(text, "General", "Lang", None);
    Ok(match v {
        Some(v) => set_in(&text, "General", "Language", Some(&v)),
        None => text,
    })
}
const MIGRATIONS: &[Migration] = &[rename_lang]; // version 0 to 1

let s = Settings::at("/tmp/telamon-examplerc");
match s.migrate(MIGRATIONS) {
    Ok(done) => log::info!("settings: {done:?}"),
    Err(e) => eprintln!("{e}"), // show it; the file is untouched
}
```

- All steps run in memory under the writer lock. Only when every step has succeeded is the old file copied to `<name>.bak` (a temp file and a rename, with the old file's mode) and also to `<name>.bak.v<N>`, `N` being the version it was. `<name>.bak` is the latest call's copy and is replaced by a later call; the `.bak.v<N>` files are not, so an older schema's backup survives a later migration. The `.bak.v<N>` just made and the 2 highest other versions are kept, and then the new text replaces the file atomically. A failing step, a step whose text is over 4 MB, or a panic in one (caught only with `panic = "unwind"`), returns `MigrateError::Failed { from, source }` and writes nothing, not even the `.bak`. Its message names the version: "settings migration from version 1 to 2 failed: ...".
- A step runs under the file lock and the writer lock. It must work only on the text it is given: `Settings::set` or `migrate` called from inside a step (on any file) returns an error at once instead of hanging.
- A file newer than the app (`SchemaVersion` above `migrations.len()`) is never written: `migrate` returns `MigrateError::Newer { found, known }` and logs a warning. The file still reads. The app chooses: read it and avoid `set`, or quit with a message. Nothing stops a later `set`, so the app must not call it.
- A missing or empty file is created at the current version (`Migrated::Created`), so a later start does not run the migrations on new data.
- `SchemaVersion` that is not a number is `MigrateError::BadVersion` (the value cut to 64 characters); an immutable one is `Io` with `PermissionDenied`; a file that is not UTF-8 is `Io` with `InvalidData`.

| Item | Description |
|---|---|
| `type Migration = fn(&str) -> Result<String, Box<dyn Error + Send + Sync>>` | One step: the whole text in, the new text out |
| `fn migrate(&self, migrations: &[Migration]) -> Result<Migrated, MigrateError>` | As above |
| `enum Migrated` | `UpToDate`, `Created { to }`, `Upgraded { from, to }` (`Debug, Clone, Copy, PartialEq, Eq`) |
| `enum MigrateError` (`#[non_exhaustive]`) | `Failed { from, source }`, `Newer { found, known }`, `BadVersion(String)`, `Io(io::Error)`; implements `Error` and `Display` |
| `SCHEMA_GROUP`, `SCHEMA_KEY` | `pub const &str`: `"Telamon"`, `"SchemaVersion"` |

## Watching for changes

`Settings::watch(on_change)` calls `on_change(&Snapshot)` on a thread named `telamon-settings-watch` whenever the file's contents change, and returns a `SettingsWatcher`. Dropping the watcher stops the watch; a callback already running finishes and none starts afterwards.

```rust
let s = Settings::at("/tmp/telamon-examplerc");
let _watch = s.watch(|new| {
    let dark = new.get_bool("General", "Dark");
    // hand `dark` to the UI thread
})?;
```

- The directory is watched with inotify (through `libc`; no new dependency), not the file, so a write in place, an editor's rename over the file, a delete and a re-create are all seen. Other files in the directory are ignored.
- A symlinked file is followed: its target's directory is watched as well, and the link is resolved again when the link itself changes (checked only on events for the link's name), so a retargeted link is followed. A target in a directory that does not exist yet is watched through its nearest existing ancestor until it appears. Only the last component of the path is resolved as a link: a symlinked directory higher up is watched by what it points to (its inode), so replacing that link by another is not noticed.
- Events are debounced: the callback runs once the file has been quiet for `DEBOUNCE` (200 ms), but a steady stream of events holds it back at most 2 seconds. It runs only when the text differs from the last one reported; the text when `watch` was called is the first. The app's own `set` that changes nothing, or a delete and re-create with the same text, call nothing.
- A deleted file gives a `Snapshot` where `exists()` is false and every `get` is `None`. `exists()` is false only for a missing file. A file that is there but cannot be read (over 4 MB, not a regular file such as a FIFO, no permission) is logged and no callback runs: the last values stand until it reads again, so an app that writes defaults when `!exists()` never overwrites it.
- `watch` creates the file's own directory if it is missing, as `set` would. After that the watch creates nothing: if the directory is removed or renamed away (an uninstall, `rm -rf`), the file is reported as missing and the watch follows the nearest existing ancestor until the directory comes back, then watches it again. If it cannot watch again it logs a warning and ends.
- A panic in the callback is logged and the watch goes on (only with `panic = "unwind"`, the default).
- The callback runs on the watch thread, not the UI thread, and **must hand work off without blocking**: post with a non-blocking `queue` (see [task](https://telamon.eterneon.net/framework/telamon-framework-core/task.md)), never a call that waits for the UI thread.
- Dropping the `SettingsWatcher` stops the watch. It waits up to 1 second for the watch thread. If a callback is still running then, the drop gives up with a logged warning and leaves the thread to end by itself, so a drop cannot hang the UI. A callback that has not started is not started, but one already past the stop check when the drop began can still start. Everything the callback captures must therefore stay valid until it returns (no raw pointers, no unprotected references to what the dropper frees), and a `queue` to a QObject must cope with a target that is gone. Dropping it from inside its own callback does not wait.
- A file that is unreadable when `watch` starts counts as unknown: the first successful read is reported.
- Errors: `NotFound` with no home directory; the OS error when inotify has no instances or watches left, or the directory cannot be read.

| Item | Description |
|---|---|
| `fn watch<F: FnMut(&Snapshot) + Send + 'static>(&self, on_change: F) -> io::Result<SettingsWatcher>` | Starts the watch |
| `struct SettingsWatcher` | Keeps the watch alive; stops it on drop |
| `struct Snapshot` | The file read once: `exists() -> bool`, `get(group, key) -> Option<String>`, `get_bool(group, key) -> Option<bool>` (`Debug, Clone, PartialEq, Eq`) |
| `DEBOUNCE` | `pub const Duration`, 200 ms |

## Settings

`#[derive(Debug, Clone, PartialEq, Eq)]`

| Method | Description |
|---|---|
| `fn for_app(app: &AppInfo) -> Settings` | The file `<short_name>rc` in `config_dir()`, for example `telamon-updaterrc`. The first time, when that file does not exist (not even as a link) and `<legacy_short_name>rc` (`atlas-updaterrc`, written by 1.x) is a regular file of at most 4 MB, the old file is copied to the new name with its `[Atlas]` group renamed to `[Telamon]`, keeping its mode, atomically and never over a file another process made; the old file stays for an app that has not moved yet. A failure is logged and the app starts with defaults |
| `fn at(path: impl Into<PathBuf>) -> Settings` | Any path |
| `fn path(&self) -> &Path` | The file's path |
| `fn get(&self, group: &str, key: &str) -> Option<String>` | The unescaped value, or `None` if the file, group or key is missing |
| `fn get_bool(&self, group: &str, key: &str) -> Option<bool>` | `true`, `1`, `yes`, `on` and `false`, `0`, `no`, `off` (any case); anything else, or a missing key, is `None` |
| `fn set(&self, group: &str, key: &str, value: Option<&str>) -> io::Result<()>` | Sets the key, or removes it for `None`. Errors: `InvalidInput` for a group or key that cannot be written as one (empty, control characters, brackets, `=` in a key, edge spaces, a leading `#` or `;`), `PermissionDenied` for an immutable one, `NotFound` when there is no home directory |

## Functions and constants

| Name | Kind | Description |
|---|---|---|
| `FORMAT` | `pub const u32` | The format version written, `1` |
| `config_dir()` | `pub fn config_dir() -> PathBuf` | `$XDG_CONFIG_HOME`, else `~/.config`. Relative values are ignored, as the XDG spec says. With neither, `/nonexistent`, where nothing is written |
| `get_in` | `pub fn get_in(text: &str, group: &str, key: &str) -> Option<String>` | Reads a value from KConfig text you already hold |
| `set_in` | `pub fn set_in(text: &str, group: &str, key: &str, value: Option<&str>) -> String` | Returns `text` with the key set (escaped) or removed. Does no file work and no name checks |
