Merge remote-tracking branch 'origin/main' into codex/craft-print-fix-merge-20261012T210558Z

# Conflicts:
#	Cargo.lock
This commit is contained in:
skylar taylor-barrick
2026-10-12 23:49:49 +02:00
19 changed files with 2341 additions and 0 deletions
Generated
+57
View File
@@ -543,6 +543,17 @@ dependencies = [
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-settings"
version = "0.1.0"
dependencies = [
"craft-atomic",
"dirs",
"serde",
"tempfile",
"toml_edit",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -653,6 +664,27 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -2379,6 +2411,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -2823,6 +2861,16 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_users"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [
"libredox",
"thiserror 2.0.21",
]
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
@@ -3884,8 +3932,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
@@ -3898,6 +3949,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tower"
version = "0.5.3"
+1
View File
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/craft-libs"
craft-atomic = { path = "crates/atomic" }
craft-print = { path = "crates/print" }
craft-quit = { path = "crates/quit" }
craft-settings = { path = "crates/settings" }
craft-ui = { path = "crates/ui" }
egui = { version = "0.36", default-features = false }
+1
View File
@@ -30,6 +30,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-quit`](crates/quit/src/lib.rs): native macOS quit, egui close handling and an optional
app menu. Unsaved-document decisions and save prompts stay in each app.
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "craft-settings"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Shared settings storage for the Craft suite: platform folders, config.toml, atomic saves, recovery and migration"
[lints]
workspace = true
[dependencies]
craft-atomic = { workspace = true }
serde = { version = "1", features = ["derive"] }
toml_edit = { version = "0.25", features = ["serde"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
dirs = "7"
[dev-dependencies]
tempfile = "3"
+182
View File
@@ -0,0 +1,182 @@
# Craft Settings
`craft-settings` stores an application's settings in `config.toml`: it picks the folder, loads
without ever failing, saves atomically while the application runs, keeps a backup, moves damaged
files aside and runs schema migrations. Each application keeps its own settings type and
defaults. The suite-wide policy (what belongs where) is in
[docs/settings-storage.md](../../docs/settings-storage.md).
Atomic writes come from [`craft-atomic`](../atomic/README.md).
## Boundaries
- Never crashes: every error comes back in a `LoadReport`, `TickResult` or `Result`; the crate
denies `unwrap`, `expect`, `panic` and friends, and the workspace forbids `unsafe`.
- No application code: it depends only on `craft-atomic`, `serde`, `toml_edit` and `dirs`, and
knows nothing about any app's settings, defaults, UI or file formats. Apps supply their type
and their own parsers for older files.
- No UI: it reports; the application decides what to show.
## Defining settings
```rust
use craft_settings::{DocumentMut, MigrateError, Settings};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
#[serde(default)]
struct Prefs {
general: General,
appearance: Appearance,
}
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
#[serde(default)]
struct General { language: String, autosave_minutes: u32 }
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
#[serde(default)]
struct Appearance { theme: String, ui_scale: f64 }
impl Settings for Prefs {
const APP_ID: &'static str = "photocraft";
const SCHEMA_VERSION: u32 = 1;
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
// Called once per step for files older than SCHEMA_VERSION, before typed parsing.
Ok(())
}
}
```
Rules for the type:
- Put `#[serde(default)]` on every struct, so a missing key falls back to its default.
- Do not use `#[serde(deny_unknown_fields)]`: keys the application does not know are kept in
the file on purpose.
- Each top-level field becomes a `[section]`. A value that does not fit resets only its section.
## Using the store
```rust,ignore
use craft_settings::{Location, SaveStatus, SettingsStore};
// Once at startup. Never fails; look at the report for anything to show the user.
let (mut store, report) = SettingsStore::<Prefs>::open(Location::Auto);
if report.has_problems() {
// report.broken_moved_to, report.section_errors, report.read_only
}
// Every frame: read is in-memory.
let scale = store.get().appearance.ui_scale;
// On change: only marks the store dirty if the value actually changed.
store.update(|p| p.appearance.theme = "dark".into());
// Every frame: saves 500 ms after the last change, retries failures with backoff.
let tick = store.tick(std::time::Instant::now());
if let SaveStatus::Failed(err) = &tick.status {
// show it; the store keeps retrying
}
if let Some(deadline) = tick.next_deadline {
// eframe only repaints on input, so ask for one at the deadline or an idle app never saves:
ctx.request_repaint_after(deadline.saturating_duration_since(std::time::Instant::now()));
}
// On exit.
let _ = store.flush();
```
`SettingsStore::open_at(dir)` opens a store in a given folder, for settings that travel with a
library or project. `SettingsStore::open(Location::Memory)` keeps settings in memory only and
reads and writes nothing, for tests, demos and switches like `DESIGNCRAFT_NO_PREFS`; the app
uses the same `get`/`update`/`tick`/`flush` calls either way. `OpenOptions` adds older files to
import, a different debounce, and a `Probe` for tests.
## Where the file goes
`Location::Auto` applies these rules in order; the report says which one won:
1. `Location::Dir(path)`: an application override, such as a `--config-dir` flag.
2. `<APP>_CONFIG_DIR` (`PHOTOCRAFT_CONFIG_DIR`). Set but **empty** means isolated: the store
is in memory only and nothing is read or written, so tests and agents never touch the real
profile.
3. A portable marker next to the executable (next to the `.app` bundle on macOS): by default a
file named `portable`, with settings in `config/` beside it. An app with an existing portable
layout keeps it by overriding `Settings::PORTABLE_MARKERS` and `Settings::PORTABLE_DIR`:
```rust,ignore
const PORTABLE_MARKERS: &'static [&'static str] = &["portable.txt", "PhotoCraft.portable"];
const PORTABLE_DIR: &'static str = "PhotoCraftData";
```
4. The platform config folder: `$XDG_CONFIG_HOME/<app>/` or `~/.config/<app>/` on Linux,
`~/Library/Application Support/<app>/` on macOS, `%APPDATA%\<app>\` on Windows.
With no folder (isolated or `Location::Memory`), `store.dir()` and `store.path()` are `None`,
`is_persistent()` is false, legacy files are not imported, and `flush()` returns `Ok`.
Otherwise the folder is created at open. If it cannot be created or written, the store opens read-only
and the report says so, so the application can tell the user at startup rather than on the
first save. `paths::cache_dir` and `paths::data_dir` give the matching cache and data folders for
things that do not belong in `config.toml`.
## Files
- `config.toml`: the settings, starting with `schema_version = N`. Written on the first
`flush()` even when nothing changed, so users can find it. Created `0600` on Unix; a mode the
user changes by hand is kept on later saves.
- `config.toml.bak`: the previous version, refreshed before each save. On Unix it always has
the same mode as `config.toml`; an older, broader backup is tightened on the next save.
- `config.broken-<unix-ts>.toml`: a file that was not valid TOML, moved aside. Never deleted.
## What happens when loading
| File state | Result |
|---|---|
| Missing | Defaults, or the first `LegacyFile` that parses. The old file is left in place so a rollback still works. |
| Not valid TOML | Moved aside; defaults; `report.broken_moved_to`. If it cannot be moved, read-only. |
| Valid TOML, a section does not fit | That section's defaults; the rest kept; `report.section_errors`. |
| Older `schema_version` | `Settings::migrate` once per step, then saved; `report.migrated_from`. |
| Newer `schema_version` | Values shown, store read-only, so an older build never overwrites a newer file. |
| Migration step fails | Store read-only, file untouched. |
## What happens when saving
Values are merged into the document that was loaded: tables merge key by key, arrays and
scalars are replaced whole, comments, key order, unknown keys and each replaced line's trailing
comment stay. Each write goes through `craft-atomic`, so a failed save leaves the old file intact.
Just before each write the store reads the file and compares its **contents** (not its mtime,
which can stay the same on FAT, HFS+ or network shares) with what it last read or wrote:
| File on disk | What the save does |
|---|---|
| Unchanged | Backs it up to `config.toml.bak`, then writes. |
| Changed, parses | Adopts it so its comments and unknown keys survive, backs it up, then writes this store's values over its known keys. |
| Changed, does not parse | Moves it to `config.broken-<ts>.toml` (the edit is kept; `take_moved_aside()` returns the path to show), leaves the previous backup alone, then writes. |
| Blank | Leaves the previous backup alone, then writes. |
| From a newer build, or its migration fails | Writes nothing; the store becomes read-only and the save returns `SettingsError::ReadOnly`, the same as at startup. |
### Concurrency contract
Saving is **best effort, last writer wins**, not multi-process locking. Hand edits and a
second instance's unknown keys and comments are preserved as above, but two processes saving
within the same moment can still race: the later write wins on known keys. Settings are small
and change rarely, so this is the contract apps should assume. An app that needs strict
coordination between processes (for example a launcher writing another app's settings) should
add its own lock around `flush()`.
## Validation
Before merging (from `AGENTS.md`), plus the wasm check:
```sh
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo check --target wasm32-unknown-unknown -p craft-settings
```
The tests in `tests/` write only to temporary folders and never touch the real config folder.
On `wasm32` only the `Settings` trait, the document helpers and the error types are available.
+109
View File
@@ -0,0 +1,109 @@
//! TOML document handling: `schema_version`, per-section parsing and comment-preserving merges.
use toml_edit::{DocumentMut, Item, Table, TableLike};
use crate::Settings;
use crate::error::SectionError;
/// The key written first in every `config.toml`.
pub const SCHEMA_VERSION_KEY: &str = "schema_version";
/// Parses `text` as TOML, keeping comments and formatting.
pub fn parse(text: &str) -> Result<DocumentMut, toml_edit::TomlError> {
text.parse()
}
/// Reads `schema_version`; `None` when the key is missing or not a non-negative integer.
pub fn schema_version(doc: &DocumentMut) -> Option<u32> {
doc.get(SCHEMA_VERSION_KEY)?.as_integer().and_then(|v| u32::try_from(v).ok())
}
/// Sets `schema_version`, keeping its position if it already exists.
pub fn set_schema_version(doc: &mut DocumentMut, version: u32) {
doc[SCHEMA_VERSION_KEY] = toml_edit::value(i64::from(version));
}
/// An empty document carrying only `schema_version`.
pub fn new_document(version: u32) -> DocumentMut {
let mut doc = DocumentMut::new();
set_schema_version(&mut doc, version);
doc
}
/// Turns `doc` into `T`, one top-level section at a time.
///
/// A section that does not fit `T` is reported and replaced by its default; every other section
/// is kept. This is what keeps a renamed enum variant from resetting the whole file.
pub fn deserialize_sections<T: Settings>(doc: &DocumentMut) -> (T, Vec<SectionError>) {
let defaults = T::default();
let Ok(mut merged) = toml_edit::ser::to_document(&defaults) else {
return (defaults, Vec::new());
};
let mut errors = Vec::new();
for (key, item) in doc.iter() {
if key == SCHEMA_VERSION_KEY {
continue;
}
let mut candidate = merged.clone();
candidate[key] = item.clone();
match toml_edit::de::from_document::<T>(candidate.clone()) {
Ok(_) => merged = candidate,
Err(e) => errors.push(SectionError { section: key.to_owned(), message: e.to_string() }),
}
}
let value = toml_edit::de::from_document::<T>(merged).unwrap_or(defaults);
(value, errors)
}
/// Writes `value` into `doc`, keeping comments, key order and keys `value` does not have.
///
/// Tables merge key by key; arrays, arrays of tables and scalars are replaced whole.
pub fn merge_into<T: Settings>(doc: &mut DocumentMut, value: &T) -> Result<(), toml_edit::ser::Error> {
let mut fresh = toml_edit::ser::to_document(value)?;
expand_inline_tables(fresh.as_table_mut());
merge_tables(doc.as_table_mut(), fresh.as_table());
Ok(())
}
/// Serde writes nested structs as inline tables (`general = { … }`); files are nicer to read
/// and edit as `[general]` sections, so new sections are converted before merging.
fn expand_inline_tables(table: &mut Table) {
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_owned()).collect();
for key in keys {
let Some(item) = table.get_mut(&key) else { continue };
if let Some(inline) = item.as_inline_table() {
*item = Item::Table(inline.clone().into_table());
}
if let Some(sub) = item.as_table_mut() {
expand_inline_tables(sub);
}
}
}
fn merge_tables(into: &mut dyn TableLike, from: &dyn TableLike) {
for (key, item) in from.iter() {
match into.get_mut(key) {
Some(existing) if existing.is_table_like() && item.is_table_like() => {
if let (Some(existing), Some(fresh)) = (existing.as_table_like_mut(), item.as_table_like()) {
merge_tables(existing, fresh);
}
}
Some(existing) => {
// Keep the spacing and trailing comment of the line being replaced.
let mut fresh = item.clone();
if let (Some(old), Some(new)) = (existing.as_value(), fresh.as_value_mut()) {
*new.decor_mut() = old.decor().clone();
}
*existing = fresh;
}
None => {
into.insert(key, item.clone());
}
}
}
}
/// Renders `doc` as text.
pub fn render(doc: &DocumentMut) -> String {
doc.to_string()
}
+163
View File
@@ -0,0 +1,163 @@
//! Reports and errors returned by the store.
use std::fmt;
use std::io;
use std::path::PathBuf;
use std::time::Instant;
/// Why a schema migration step failed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrateError {
/// The `schema_version` the step started from.
pub from: u32,
/// What went wrong.
pub message: String,
}
impl MigrateError {
/// A failure migrating from version `from`.
pub fn new(from: u32, message: impl Into<String>) -> Self {
Self { from, message: message.into() }
}
}
impl fmt::Display for MigrateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "migration from schema version {} failed: {}", self.from, self.message)
}
}
impl std::error::Error for MigrateError {}
/// A `[section]` that parsed as TOML but did not fit the settings type; its defaults were used.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SectionError {
/// The top-level key.
pub section: String,
/// The deserialization error.
pub message: String,
}
/// Why the store will not write `config.toml`.
#[derive(Debug)]
pub enum ReadOnlyReason {
/// The configuration folder could not be created.
DirUnavailable(io::Error),
/// The configuration folder exists but is not writable.
DirNotWritable(io::Error),
/// `config.toml` exists but could not be read.
Unreadable(io::Error),
/// `config.toml` is not valid TOML and could not be moved aside.
CannotMoveAside(io::Error),
/// The file was written by a newer build.
NewerSchema {
/// Version in the file.
file: u32,
/// Version this build knows.
app: u32,
},
/// A schema migration step failed; the file is left untouched.
Migration(MigrateError),
}
impl fmt::Display for ReadOnlyReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DirUnavailable(e) => write!(f, "settings folder cannot be created: {e}"),
Self::DirNotWritable(e) => write!(f, "settings folder is not writable: {e}"),
Self::Unreadable(e) => write!(f, "config.toml cannot be read: {e}"),
Self::CannotMoveAside(e) => write!(f, "config.toml is damaged and cannot be moved aside: {e}"),
Self::NewerSchema { file, app } => write!(f, "config.toml has schema version {file}, newer than this build's {app}"),
Self::Migration(e) => write!(f, "{e}"),
}
}
}
/// Where the loaded values came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
/// Parsed from `config.toml`.
File,
/// `config.toml` did not exist or was unusable; defaults were used.
Defaults,
/// Imported from an older per-app file (left in place).
Legacy(PathBuf),
}
/// What happened while opening the store. Nothing in here is fatal; the store always opens.
#[derive(Debug)]
pub struct LoadReport {
/// The configuration folder; `None` when nothing is read or written (isolated or in memory).
pub dir: Option<PathBuf>,
/// The rule that chose `dir` (see [`crate::Rule`]), as text so the report stays `Debug`-only.
pub rule: String,
/// Where the values came from.
pub source: Source,
/// Set when a damaged `config.toml` was renamed; the new name.
pub broken_moved_to: Option<PathBuf>,
/// Sections whose values were replaced by defaults.
pub section_errors: Vec<SectionError>,
/// Set when the file was upgraded from an older `schema_version`.
pub migrated_from: Option<u32>,
/// Set when the store will not save.
pub read_only: Option<ReadOnlyReason>,
}
impl LoadReport {
/// True when something should be shown to the user.
pub fn has_problems(&self) -> bool {
self.broken_moved_to.is_some() || !self.section_errors.is_empty() || self.read_only.is_some()
}
}
/// A save failure.
#[derive(Debug)]
pub enum SettingsError {
/// The store is read-only; see the reason in the load report.
ReadOnly(String),
/// The settings could not be turned into TOML.
Serialize(String),
/// Writing `config.toml` failed.
Io(io::Error),
}
impl fmt::Display for SettingsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ReadOnly(why) => write!(f, "settings are read-only: {why}"),
Self::Serialize(e) => write!(f, "settings cannot be serialized: {e}"),
Self::Io(e) => write!(f, "settings could not be saved: {e}"),
}
}
}
impl std::error::Error for SettingsError {}
impl From<io::Error> for SettingsError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
/// Outcome of one [`crate::SettingsStore::tick`].
#[derive(Debug)]
pub enum SaveStatus {
/// Nothing to save.
Idle,
/// Changes are waiting for the debounce or retry delay.
Pending,
/// A save just succeeded.
Saved,
/// A save just failed; it will be retried.
Failed(SettingsError),
}
/// What [`crate::SettingsStore::tick`] returns.
#[derive(Debug)]
pub struct TickResult {
/// What happened this tick.
pub status: SaveStatus,
/// When to call `tick` again so a pending save happens even if the app is idle.
/// Pass it to `egui::Context::request_repaint_after`.
pub next_deadline: Option<Instant>,
}
+91
View File
@@ -0,0 +1,91 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Shared settings storage for the Craft suite.
//!
//! Each application keeps its own settings type and defaults. This crate decides where the
//! `config.toml` lives, loads it without ever failing, saves it atomically while the
//! application runs, keeps a backup, moves damaged files aside and runs schema migrations.
//!
//! Hand edits survive: the file is kept in memory as a [`toml_edit::DocumentMut`] and saves
//! merge values into it, so comments and keys the application does not know stay in place.
//!
//! ```no_run
//! use craft_settings::{Location, Settings, SettingsStore};
//! use serde::{Deserialize, Serialize};
//!
//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
//! #[serde(default)]
//! struct Prefs {
//! appearance: Appearance,
//! }
//!
//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
//! #[serde(default)]
//! struct Appearance {
//! theme: String,
//! }
//!
//! impl Settings for Prefs {
//! const APP_ID: &'static str = "photocraft";
//! const SCHEMA_VERSION: u32 = 1;
//! }
//!
//! let (mut store, report) = SettingsStore::<Prefs>::open(Location::Auto);
//! store.update(|p| p.appearance.theme = "dark".into());
//! // Call `store.tick(Instant::now())` every frame and `store.flush()` on exit.
//! ```
pub mod document;
pub mod error;
pub mod migrate;
#[cfg(not(target_arch = "wasm32"))]
pub mod paths;
#[cfg(not(target_arch = "wasm32"))]
pub mod recovery;
#[cfg(not(target_arch = "wasm32"))]
pub mod store;
pub use error::{LoadReport, MigrateError, ReadOnlyReason, SaveStatus, SectionError, SettingsError, Source, TickResult};
pub use migrate::LegacyFile;
#[cfg(not(target_arch = "wasm32"))]
pub use paths::{Location, Portable, Probe, Resolved, Rule};
#[cfg(not(target_arch = "wasm32"))]
pub use store::{OpenOptions, SettingsStore};
pub use toml_edit::DocumentMut;
use serde::Serialize;
use serde::de::DeserializeOwned;
/// Suite-default portable names, kept here so the trait also compiles on wasm.
mod paths_default {
pub const MARKERS: &[&str] = &["portable"];
pub const DIR: &str = "config";
}
/// An application's settings type.
///
/// Derive `Serialize`, `Deserialize`, `Default`, `Clone` and `PartialEq`, and put
/// `#[serde(default)]` on every struct so missing keys fall back to defaults. Do not use
/// `#[serde(deny_unknown_fields)]`: unknown keys are kept in the file on purpose.
pub trait Settings: Serialize + DeserializeOwned + Default + Clone + PartialEq {
/// Lowercase application id, used for the folder name and the `<APP>_CONFIG_DIR` variable.
const APP_ID: &'static str;
/// Version written as `schema_version` at the top of `config.toml`.
const SCHEMA_VERSION: u32;
/// File names next to the executable that turn on portable mode (any one; contents ignored).
/// Override to keep an existing portable layout, e.g. `&["portable.txt", "PhotoCraft.portable"]`.
const PORTABLE_MARKERS: &'static [&'static str] = paths_default::MARKERS;
/// Folder beside the executable that holds settings in portable mode.
const PORTABLE_DIR: &'static str = paths_default::DIR;
/// Upgrade a document from `from` to `from + 1`. Called once per step for files whose
/// `schema_version` is older than [`Settings::SCHEMA_VERSION`], before typed parsing.
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
let _ = (from, doc);
Ok(())
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Schema migrations and import of older per-app settings files.
use std::path::PathBuf;
use toml_edit::DocumentMut;
use crate::Settings;
use crate::document::{schema_version, set_schema_version};
use crate::error::MigrateError;
/// What [`upgrade`] found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Upgrade {
/// Already at the current version, or no `schema_version` key (treated as current).
Current,
/// Upgraded step by step from this older version.
Migrated { from: u32 },
/// Written by a newer build; the document was left alone.
Newer { file: u32 },
}
/// Brings `doc` up to [`Settings::SCHEMA_VERSION`] by calling [`Settings::migrate`] once per step.
///
/// A missing `schema_version` means a hand-written file; it is taken as current and the key is
/// added. A failed step leaves the document as that step left it; callers must not save it.
pub fn upgrade<T: Settings>(doc: &mut DocumentMut) -> Result<Upgrade, MigrateError> {
let target = T::SCHEMA_VERSION;
let Some(file) = schema_version(doc) else {
set_schema_version(doc, target);
return Ok(Upgrade::Current);
};
if file > target {
return Ok(Upgrade::Newer { file });
}
if file == target {
return Ok(Upgrade::Current);
}
for step in file..target {
T::migrate(step, doc)?;
set_schema_version(doc, step + 1);
}
Ok(Upgrade::Migrated { from: file })
}
/// An older settings file an application used before `config.toml`.
///
/// The application parses it with its own code, so this crate needs no JSON or RON support.
/// The file is read, never renamed or removed, so an older build keeps working after a rollback.
pub struct LegacyFile<T> {
/// Where the old file lives on this platform.
pub path: PathBuf,
/// Parses the file's bytes into the current settings type.
pub parse: fn(&[u8]) -> Result<T, String>,
}
impl<T> std::fmt::Debug for LegacyFile<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LegacyFile").field("path", &self.path).finish_non_exhaustive()
}
}
/// Tries each legacy file in order and returns the first one that parses, with its path.
#[cfg(not(target_arch = "wasm32"))]
pub fn import_legacy<T>(files: &[LegacyFile<T>]) -> Option<(T, PathBuf)> {
files.iter().find_map(|f| {
let bytes = std::fs::read(&f.path).ok()?;
(f.parse)(&bytes).ok().map(|v| (v, f.path.clone()))
})
}
+170
View File
@@ -0,0 +1,170 @@
//! Choosing the configuration folder.
//!
//! First match wins: an override from the application, the `<APP>_CONFIG_DIR` variable, a
//! portable marker next to the executable, then the platform's own config folder. An empty
//! `<APP>_CONFIG_DIR`, or [`Location::Memory`], means no folder at all: nothing is read or written.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// How an application spells portable mode: the marker files that turn it on (any one, contents
/// ignored) and the folder created beside the executable. Comes from [`crate::Settings`], so an
/// app that already ships portable builds keeps its existing names.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Portable {
/// File names next to the executable that switch portable mode on.
pub markers: &'static [&'static str],
/// Folder beside the executable that holds the settings in portable mode.
pub dir: &'static str,
}
impl Portable {
/// The suite default: a `portable` file puts settings in `config/`.
pub const DEFAULT: Self = Self { markers: crate::paths_default::MARKERS, dir: crate::paths_default::DIR };
}
/// Where the application wants its settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
/// Apply the rules in order.
Auto,
/// Use this folder, no questions asked (a `--config-dir` flag, or a library folder).
Dir(PathBuf),
/// Keep settings in memory only; read and write nothing (tests, demos, `<APP>_NO_PREFS`).
Memory,
}
/// Which rule chose the folder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Rule {
/// [`Location::Dir`].
Override,
/// The named environment variable.
EnvVar(String),
/// The named environment variable was set but empty: isolated, nothing read or written.
Isolated(String),
/// [`Location::Memory`].
Memory,
/// A portable marker next to the executable.
Portable,
/// The platform's config folder.
Platform,
/// No platform folder could be found; the current directory is used.
Fallback,
}
impl std::fmt::Display for Rule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Override => f.write_str("override"),
Self::EnvVar(name) => write!(f, "environment variable {name}"),
Self::Isolated(name) => write!(f, "{name} is empty: isolated, nothing read or written"),
Self::Memory => f.write_str("in memory only"),
Self::Portable => f.write_str("portable marker"),
Self::Platform => f.write_str("platform default"),
Self::Fallback => f.write_str("fallback to current directory"),
}
}
}
/// A chosen folder and the rule behind it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
/// The configuration folder; `None` for [`Rule::Isolated`] and [`Rule::Memory`].
pub dir: Option<PathBuf>,
/// Why.
pub rule: Rule,
}
/// Reads an environment variable by name.
pub type EnvReader = Box<dyn Fn(&str) -> Option<OsString>>;
/// What the rules look at. The defaults read the real process; tests supply their own.
pub struct Probe {
/// Reads an environment variable.
pub env: EnvReader,
/// The folder holding the executable, if known.
pub exe_dir: Option<PathBuf>,
/// The platform config folder (`~/.config`, `Application Support`, `%APPDATA%`).
pub platform_config_dir: Option<PathBuf>,
}
impl Default for Probe {
fn default() -> Self {
Self {
env: Box::new(|name| std::env::var_os(name)),
exe_dir: std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)),
platform_config_dir: dirs::config_dir(),
}
}
}
impl std::fmt::Debug for Probe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Probe").field("exe_dir", &self.exe_dir).field("platform_config_dir", &self.platform_config_dir).finish_non_exhaustive()
}
}
/// The `<APP>_CONFIG_DIR` variable name for an app id (`photo-craft` → `PHOTO_CRAFT_CONFIG_DIR`).
pub fn env_var_name(app_id: &str) -> String {
let mut name: String = app_id.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect();
name.push_str("_CONFIG_DIR");
name
}
/// Applies the rules for `app_id`, with the app's portable-mode names.
pub fn resolve(app_id: &str, portable: &Portable, location: &Location, probe: &Probe) -> Resolved {
match location {
Location::Dir(dir) => return Resolved { dir: Some(dir.clone()), rule: Rule::Override },
Location::Memory => return Resolved { dir: None, rule: Rule::Memory },
Location::Auto => {}
}
let var = env_var_name(app_id);
if let Some(value) = (probe.env)(&var) {
if value.is_empty() {
return Resolved { dir: None, rule: Rule::Isolated(var) };
}
return Resolved { dir: Some(PathBuf::from(value)), rule: Rule::EnvVar(var) };
}
if let Some(dir) = probe.exe_dir.as_deref().and_then(|d| portable_dir(d, portable)) {
return Resolved { dir: Some(dir), rule: Rule::Portable };
}
match &probe.platform_config_dir {
Some(base) => Resolved { dir: Some(base.join(app_id)), rule: Rule::Platform },
None => Resolved { dir: Some(PathBuf::from(".").join(app_id)), rule: Rule::Fallback },
}
}
/// The folder a portable marker points at, when `exe_dir` has one.
///
/// On macOS the executable sits in `X.app/Contents/MacOS`; the marker is looked for next to the
/// `.app` bundle, because writing inside a signed bundle breaks its signature.
fn portable_dir(exe_dir: &Path, portable: &Portable) -> Option<PathBuf> {
let base = bundle_parent(exe_dir).unwrap_or(exe_dir);
portable.markers.iter().any(|m| base.join(m).is_file()).then(|| base.join(portable.dir))
}
fn bundle_parent(exe_dir: &Path) -> Option<&Path> {
if !cfg!(target_os = "macos") || exe_dir.file_name()? != "MacOS" {
return None;
}
let contents = exe_dir.parent()?;
if contents.file_name()? != "Contents" {
return None;
}
let bundle = contents.parent()?;
if bundle.extension()? != "app" {
return None;
}
bundle.parent()
}
/// The platform cache folder for `app_id` (`%LOCALAPPDATA%` on Windows, so it does not roam).
pub fn cache_dir(app_id: &str) -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join(app_id))
}
/// The platform data folder for `app_id`, for recovery files and other non-settings state.
pub fn data_dir(app_id: &str) -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join(app_id))
}
+67
View File
@@ -0,0 +1,67 @@
//! Keeping damaged and previous files around.
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Renames a damaged `config.toml` to `config.broken-<unix-ts>.toml` beside it and returns the
/// new path. Never overwrites: a name that exists gets a `-<n>` suffix.
pub fn move_aside(path: &Path) -> io::Result<PathBuf> {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("config");
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("toml");
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let mut n = 0u32;
loop {
let name = if n == 0 { format!("{stem}.broken-{ts}.{ext}") } else { format!("{stem}.broken-{ts}-{n}.{ext}") };
let target = dir.join(name);
if target.exists() {
n = n.saturating_add(1);
if n > 1000 {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "too many broken copies"));
}
continue;
}
std::fs::rename(path, &target)?;
return Ok(target);
}
}
/// The backup path for a config file: `config.toml` → `config.toml.bak`.
pub fn backup_path(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
name.push(".bak");
path.with_file_name(name)
}
/// Writes `text`, the last version that parsed, to the backup file atomically.
///
/// The caller decides what is safe to keep: a blank or unparsable file must never replace the
/// previous backup. On Unix the backup always gets the same mode as `path` (`0600` when there is
/// none), so it is never readable by more people than the settings themselves.
pub fn write_backup(path: &Path, text: &str) -> io::Result<()> {
let backup = backup_path(path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path).map(|m| m.permissions().mode() & 0o777).unwrap_or(0o600);
// An atomic replace keeps the existing file's mode, so narrow an old, broader backup
// before new contents go into it, then match the config once written.
if backup.exists() {
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))?;
}
craft_atomic::atomic_write(&backup, text.as_bytes())?;
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
{
craft_atomic::atomic_write(&backup, text.as_bytes())
}
}
/// Checks that `dir` accepts a new file by creating and removing a probe.
pub fn check_writable(dir: &Path) -> io::Result<()> {
let probe = dir.join(format!(".craft-settings-probe-{}", std::process::id()));
std::fs::OpenOptions::new().write(true).create_new(true).open(&probe)?;
std::fs::remove_file(&probe)
}
+425
View File
@@ -0,0 +1,425 @@
//! The in-memory store: load once, read every frame, save while the application runs.
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use toml_edit::DocumentMut;
use crate::Settings;
use crate::document::{self, merge_into, set_schema_version};
use crate::error::{LoadReport, ReadOnlyReason, SaveStatus, SettingsError, Source, TickResult};
use crate::migrate::{self, LegacyFile, Upgrade};
use crate::paths::{self, Location, Portable, Probe};
use crate::recovery;
/// The settings file name.
pub const CONFIG_FILE: &str = "config.toml";
/// How long after the last change a save is attempted.
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
/// First retry delay after a failed save; each failure doubles it, up to [`MAX_RETRY_DELAY`].
pub const FIRST_RETRY_DELAY: Duration = Duration::from_secs(1);
/// Longest wait between retries.
pub const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
/// How to open a store.
pub struct OpenOptions<T> {
/// Where the settings live.
pub location: Location,
/// Older files to import when there is no `config.toml` yet.
pub legacy: Vec<LegacyFile<T>>,
/// Quiet period after a change before saving.
pub debounce: Duration,
/// What the folder rules look at.
pub probe: Probe,
}
impl<T> Default for OpenOptions<T> {
fn default() -> Self {
Self { location: Location::Auto, legacy: Vec::new(), debounce: DEFAULT_DEBOUNCE, probe: Probe::default() }
}
}
impl<T> OpenOptions<T> {
/// Options for `location`.
pub fn new(location: Location) -> Self {
Self { location, ..Self::default() }
}
/// Older files to import on first run.
pub fn legacy(mut self, files: Vec<LegacyFile<T>>) -> Self {
self.legacy = files;
self
}
/// Quiet period after a change before saving.
pub fn debounce(mut self, debounce: Duration) -> Self {
self.debounce = debounce;
self
}
/// What the folder rules look at.
pub fn probe(mut self, probe: Probe) -> Self {
self.probe = probe;
self
}
}
/// An application's settings, with the file they came from.
///
/// With no folder (an empty `<APP>_CONFIG_DIR`, or [`Location::Memory`]) the store keeps
/// everything in memory: `get`/`update` work as usual, nothing is read or written.
pub struct SettingsStore<T: Settings> {
dir: Option<PathBuf>,
path: Option<PathBuf>,
doc: DocumentMut,
value: T,
read_only: Option<String>,
dirty: bool,
written: bool,
last_change: Option<Instant>,
debounce: Duration,
failures: u32,
retry_at: Option<Instant>,
/// The file's contents as last read or written. Compared by content, not mtime, so an edit
/// within the same second (coarse timestamps on FAT, HFS+, network shares) is still seen.
seen_text: Option<String>,
/// Files that were damaged on disk at save time and moved aside; see [`SettingsStore::take_moved_aside`].
moved_aside: Vec<PathBuf>,
}
impl<T: Settings> std::fmt::Debug for SettingsStore<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SettingsStore")
.field("path", &self.path)
.field("read_only", &self.read_only)
.field("dirty", &self.dirty)
.finish_non_exhaustive()
}
}
impl<T: Settings> SettingsStore<T> {
/// Opens the store for `location`. Never fails: problems come back in the report.
pub fn open(location: Location) -> (Self, LoadReport) {
Self::open_with(OpenOptions::new(location))
}
/// Opens a store in `dir`, for settings that travel with a folder such as a library.
pub fn open_at(dir: impl Into<PathBuf>) -> (Self, LoadReport) {
Self::open(Location::Dir(dir.into()))
}
/// Opens the store with full options.
pub fn open_with(options: OpenOptions<T>) -> (Self, LoadReport) {
let portable = Portable { markers: T::PORTABLE_MARKERS, dir: T::PORTABLE_DIR };
let resolved = paths::resolve(T::APP_ID, &portable, &options.location, &options.probe);
let path = resolved.dir.as_ref().map(|d| d.join(CONFIG_FILE));
let mut report = LoadReport {
dir: resolved.dir.clone(),
rule: resolved.rule.to_string(),
source: Source::Defaults,
broken_moved_to: None,
section_errors: Vec::new(),
migrated_from: None,
read_only: None,
};
let mut store = Self {
dir: resolved.dir,
path,
doc: document::new_document(T::SCHEMA_VERSION),
value: T::default(),
read_only: None,
dirty: false,
written: false,
last_change: None,
debounce: options.debounce,
failures: 0,
retry_at: None,
seen_text: None,
moved_aside: Vec::new(),
};
let (Some(dir), Some(path)) = (store.dir.clone(), store.path.clone()) else {
return (store, report);
};
if let Err(e) = std::fs::create_dir_all(&dir) {
store.load_best_effort(&mut report);
store.set_read_only(&mut report, ReadOnlyReason::DirUnavailable(e));
return (store, report);
}
if let Err(e) = recovery::check_writable(&dir) {
store.load_best_effort(&mut report);
store.set_read_only(&mut report, ReadOnlyReason::DirNotWritable(e));
return (store, report);
}
match std::fs::read_to_string(&path) {
Ok(text) => store.load_text(&path, &text, &mut report),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if let Some((value, from)) = migrate::import_legacy(&options.legacy) {
store.value = value;
store.dirty = true;
report.source = Source::Legacy(from);
}
}
Err(e) => store.set_read_only(&mut report, ReadOnlyReason::Unreadable(e)),
}
(store, report)
}
/// The configuration folder; `None` when the store is in memory only.
pub fn dir(&self) -> Option<&Path> {
self.dir.as_deref()
}
/// The `config.toml` path; `None` when the store is in memory only.
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// True when the store reads and writes a file; false when it is in memory only.
pub fn is_persistent(&self) -> bool {
self.path.is_some()
}
/// The current settings. In memory; cheap to call every frame.
pub fn get(&self) -> &T {
&self.value
}
/// True when the store will not write the file; the load report says why.
pub fn is_read_only(&self) -> bool {
self.read_only.is_some()
}
/// Damaged `config.toml` files found and moved aside while saving (someone broke the file
/// while the app was running). Drains the list; show these to the user like
/// [`LoadReport::broken_moved_to`].
pub fn take_moved_aside(&mut self) -> Vec<PathBuf> {
std::mem::take(&mut self.moved_aside)
}
/// True when changes are waiting to be saved.
pub fn is_dirty(&self) -> bool {
self.dirty
}
/// Changes the settings. Returns true, and schedules a save, only if the value changed.
pub fn update(&mut self, f: impl FnOnce(&mut T)) -> bool {
let mut next = self.value.clone();
f(&mut next);
self.set(next)
}
/// Replaces the settings. Returns true, and schedules a save, only if the value changed.
pub fn set(&mut self, value: T) -> bool {
if value == self.value {
return false;
}
self.value = value;
self.mark_changed(Instant::now());
true
}
/// Like [`SettingsStore::update`] with an explicit clock, for tests.
pub fn update_at(&mut self, now: Instant, f: impl FnOnce(&mut T)) -> bool {
let mut next = self.value.clone();
f(&mut next);
if next == self.value {
return false;
}
self.value = next;
self.mark_changed(now);
true
}
/// Saves pending changes once the debounce or retry delay has passed. Call every frame
/// and again at `next_deadline`, so an idle application still saves.
pub fn tick(&mut self, now: Instant) -> TickResult {
if !self.dirty || self.read_only.is_some() {
return TickResult { status: SaveStatus::Idle, next_deadline: None };
}
let due = match (self.retry_at, self.last_change) {
(Some(retry), _) => retry,
(None, Some(changed)) => changed + self.debounce,
(None, None) => now,
};
if now < due {
return TickResult { status: SaveStatus::Pending, next_deadline: Some(due) };
}
match self.save() {
Ok(()) => TickResult { status: SaveStatus::Saved, next_deadline: None },
Err(e) => {
self.failures = self.failures.saturating_add(1);
let delay = retry_delay(self.failures);
self.retry_at = Some(now + delay);
TickResult { status: SaveStatus::Failed(e), next_deadline: Some(now + delay) }
}
}
}
/// Saves now. Writes the file even when nothing changed if it does not exist yet, so users
/// can find it. Call on exit.
pub fn flush(&mut self) -> Result<(), SettingsError> {
if let Some(why) = &self.read_only {
return Err(SettingsError::ReadOnly(why.clone()));
}
let Some(path) = &self.path else {
return Ok(());
};
if !self.dirty && (self.written || path.exists()) {
return Ok(());
}
self.save()
}
fn mark_changed(&mut self, now: Instant) {
// An in-memory store has nothing to save, so it never becomes dirty.
self.dirty = self.path.is_some();
self.last_change = Some(now);
}
fn set_read_only(&mut self, report: &mut LoadReport, reason: ReadOnlyReason) {
self.read_only = Some(reason.to_string());
report.read_only = Some(reason);
}
/// Reads the file if it happens to be readable, so a read-only store still shows the
/// user's values instead of defaults.
fn load_best_effort(&mut self, report: &mut LoadReport) {
if let Some(path) = &self.path
&& let Ok(text) = std::fs::read_to_string(path)
&& let Ok(mut doc) = document::parse(&text)
{
// A failed migration leaves the document half-upgraded; show defaults instead.
let Ok(upgraded) = migrate::upgrade::<T>(&mut doc) else {
return;
};
let (value, errors) = document::deserialize_sections::<T>(&doc);
self.value = value;
self.doc = doc;
self.seen_text = Some(text);
report.source = Source::File;
report.section_errors = errors;
if let Upgrade::Migrated { from } = upgraded {
report.migrated_from = Some(from);
}
}
}
fn load_text(&mut self, path: &Path, text: &str, report: &mut LoadReport) {
let mut doc = match document::parse(text) {
Ok(doc) => doc,
Err(_) => {
match recovery::move_aside(path) {
Ok(moved) => report.broken_moved_to = Some(moved),
Err(e) => self.set_read_only(report, ReadOnlyReason::CannotMoveAside(e)),
}
return;
}
};
match migrate::upgrade::<T>(&mut doc) {
Ok(Upgrade::Current) => {}
Ok(Upgrade::Migrated { from }) => {
report.migrated_from = Some(from);
self.dirty = true;
}
Ok(Upgrade::Newer { file }) => self.set_read_only(report, ReadOnlyReason::NewerSchema { file, app: T::SCHEMA_VERSION }),
Err(e) => {
self.set_read_only(report, ReadOnlyReason::Migration(e));
return;
}
}
let (value, errors) = document::deserialize_sections::<T>(&doc);
self.value = value;
self.doc = doc;
self.seen_text = Some(text.to_owned());
report.source = Source::File;
report.section_errors = errors;
}
fn save(&mut self) -> Result<(), SettingsError> {
if let Some(why) = &self.read_only {
return Err(SettingsError::ReadOnly(why.clone()));
}
let Some(path) = self.path.clone() else {
self.dirty = false;
return Ok(());
};
if let Some(on_disk) = self.check_disk(&path)? {
recovery::write_backup(&path, &on_disk)?;
}
merge_into(&mut self.doc, &self.value).map_err(|e| SettingsError::Serialize(e.to_string()))?;
set_schema_version(&mut self.doc, T::SCHEMA_VERSION);
let text = document::render(&self.doc);
craft_atomic::atomic_write_with(&path, |file| {
restrict_permissions(file)?;
file.write_all(text.as_bytes())
})?;
self.seen_text = Some(text);
self.dirty = false;
self.written = true;
self.failures = 0;
self.retry_at = None;
Ok(())
}
/// Looks at the file on disk just before a save, and returns its text when it is a good
/// backup (it parses and is not blank).
///
/// The contract is best effort, not multi-process locking: if another instance or a hand
/// edit changed the file since this store last read or wrote it, the new document is adopted
/// so its comments and unknown keys survive, and this store's values then win on known keys.
/// A file that no longer parses is moved aside (the edit is kept, the previous backup stays),
/// and a file from a newer build, or one whose migration fails, makes the store read-only
/// instead of overwriting it, the same as at startup.
fn check_disk(&mut self, path: &Path) -> Result<Option<String>, SettingsError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let Ok(mut doc) = document::parse(&text) else {
self.moved_aside.push(recovery::move_aside(path)?);
return Ok(None);
};
if self.seen_text.as_deref() != Some(text.as_str()) {
match migrate::upgrade::<T>(&mut doc) {
Ok(Upgrade::Newer { file }) => {
let why = ReadOnlyReason::NewerSchema { file, app: T::SCHEMA_VERSION }.to_string();
self.read_only = Some(why.clone());
return Err(SettingsError::ReadOnly(why));
}
Ok(_) => self.doc = doc,
// Same as at startup: leave the outside file untouched and stop saving.
Err(e) => {
let why = ReadOnlyReason::Migration(e).to_string();
self.read_only = Some(why.clone());
return Err(SettingsError::ReadOnly(why));
}
}
}
Ok((!text.trim().is_empty()).then_some(text))
}
}
/// Exponential backoff: 1 s, 2 s, 4 s, … capped at [`MAX_RETRY_DELAY`].
pub fn retry_delay(failures: u32) -> Duration {
let exp = failures.saturating_sub(1).min(16);
(FIRST_RETRY_DELAY * 2u32.saturating_pow(exp)).min(MAX_RETRY_DELAY)
}
/// New files are owner-only; on a replace `craft-atomic` keeps the existing file's mode.
#[cfg(unix)]
fn restrict_permissions(file: &std::fs::File) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
fn restrict_permissions(_file: &std::fs::File) -> io::Result<()> {
Ok(())
}
+96
View File
@@ -0,0 +1,96 @@
#![allow(dead_code)]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
use craft_settings::{DocumentMut, Location, MigrateError, OpenOptions, Probe, Settings, SettingsStore};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)]
#[serde(default)]
pub struct Prefs {
pub general: General,
pub appearance: Appearance,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
#[serde(default)]
pub struct General {
pub language: String,
pub autosave_minutes: u32,
pub recent: Vec<String>,
}
impl Default for General {
fn default() -> Self {
Self { language: "en".into(), autosave_minutes: 5, recent: Vec::new() }
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
#[serde(default)]
pub struct Appearance {
pub theme: Theme,
pub ui_scale: f64,
}
impl Default for Appearance {
fn default() -> Self {
Self { theme: Theme::Light, ui_scale: 1.0 }
}
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
Light,
Dark,
}
impl Settings for Prefs {
const APP_ID: &'static str = "testcraft";
const SCHEMA_VERSION: u32 = 2;
/// Version 1 stored the theme as `[appearance] dark = true`.
fn migrate(from: u32, doc: &mut DocumentMut) -> Result<(), MigrateError> {
match from {
1 => {
let dark = doc.get("appearance").and_then(|a| a.get("dark")).and_then(|d| d.as_bool());
if let Some(dark) = dark {
doc["appearance"]["theme"] = toml_edit::value(if dark { "dark" } else { "light" });
if let Some(table) = doc["appearance"].as_table_like_mut() {
table.remove("dark");
}
}
Ok(())
}
other => Err(MigrateError::new(other, "unknown version")),
}
}
}
/// A probe that sees no environment, no executable and no platform folder.
pub fn empty_probe() -> Probe {
Probe { env: Box::new(|_| None), exe_dir: None, platform_config_dir: None }
}
pub fn probe_with_env(name: &'static str, value: &'static str) -> Probe {
Probe { env: Box::new(move |n| (n == name).then(|| OsString::from(value))), exe_dir: None, platform_config_dir: None }
}
pub fn open_in(dir: &Path) -> (SettingsStore<Prefs>, craft_settings::LoadReport) {
SettingsStore::open_with(OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(empty_probe()).debounce(Duration::from_millis(100)))
}
pub fn config_path(dir: &Path) -> PathBuf {
dir.join("config.toml")
}
pub fn write_config(dir: &Path, text: &str) {
std::fs::write(config_path(dir), text).unwrap();
}
pub fn read_config(dir: &Path) -> String {
std::fs::read_to_string(config_path(dir)).unwrap()
}
+74
View File
@@ -0,0 +1,74 @@
mod common;
use std::time::{Duration, Instant};
use common::{Prefs, Theme, empty_probe, probe_with_env};
use craft_settings::{Location, OpenOptions, SaveStatus, SettingsStore, Source};
/// A probe whose platform folder is `dir`, so any write would land there.
fn probe_in(dir: &std::path::Path, env: Option<(&'static str, &'static str)>) -> craft_settings::Probe {
let mut probe = match env {
Some((name, value)) => probe_with_env(name, value),
None => empty_probe(),
};
probe.platform_config_dir = Some(dir.to_path_buf());
probe
}
fn assert_untouched(dir: &std::path::Path) {
let entries: Vec<_> = std::fs::read_dir(dir).unwrap().collect();
assert!(entries.is_empty(), "nothing may be written: {entries:?}");
}
fn exercise(store: &mut SettingsStore<Prefs>) {
assert!(!store.is_persistent());
assert_eq!(store.dir(), None);
assert_eq!(store.path(), None);
assert!(store.update(|p| p.appearance.theme = Theme::Dark), "updates still apply");
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert!(!store.is_dirty(), "nothing to save");
assert!(matches!(store.tick(Instant::now() + Duration::from_secs(5)).status, SaveStatus::Idle));
store.flush().unwrap();
}
#[test]
fn empty_env_var_reads_and_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
// A real settings file in the platform folder must not be read either.
std::fs::create_dir(tmp.path().join("testcraft")).unwrap();
std::fs::write(tmp.path().join("testcraft/config.toml"), "schema_version = 2\n[general]\nlanguage = \"de\"\n").unwrap();
let (mut store, report) =
SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Auto).probe(probe_in(tmp.path(), Some(("TESTCRAFT_CONFIG_DIR", "")))));
assert_eq!(report.dir, None);
assert_eq!(report.source, Source::Defaults);
assert!(report.rule.contains("isolated"), "{}", report.rule);
assert!(!report.has_problems(), "{report:?}");
assert_eq!(store.get().general.language, "en", "the real profile is not read");
exercise(&mut store);
assert_eq!(
std::fs::read_to_string(tmp.path().join("testcraft/config.toml")).unwrap(),
"schema_version = 2\n[general]\nlanguage = \"de\"\n",
"the real profile is not written"
);
assert_eq!(std::fs::read_dir(tmp.path().join("testcraft")).unwrap().count(), 1);
}
#[test]
fn memory_location_reads_and_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Memory).probe(probe_in(tmp.path(), None)));
assert_eq!(report.rule, "in memory only");
exercise(&mut store);
assert_untouched(tmp.path());
}
#[test]
fn memory_ignores_legacy_files() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("prefs.json");
std::fs::write(&old, "{}").unwrap();
let files = vec![craft_settings::LegacyFile { path: old, parse: |_| Ok(Prefs::default()) }];
let (_, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Memory).probe(empty_probe()).legacy(files));
assert_eq!(report.source, Source::Defaults, "an isolated store must not import from the real profile");
}
+105
View File
@@ -0,0 +1,105 @@
mod common;
use std::time::Duration;
use common::{Prefs, Theme, empty_probe, open_in, read_config, write_config};
use craft_settings::{LegacyFile, Location, OpenOptions, SettingsStore, Source};
#[test]
fn older_schema_is_migrated_step_by_step_and_saved() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 1\n\n# keep me\n[appearance]\ndark = true\nui_scale = 1.25\n");
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.migrated_from, Some(1), "{report:?}");
assert!(report.section_errors.is_empty(), "{report:?}");
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert_eq!(store.get().appearance.ui_scale, 1.25);
assert!(store.is_dirty(), "a migrated file is written back");
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.starts_with("schema_version = 2\n"), "{text}");
assert!(text.contains("# keep me"), "{text}");
assert!(text.contains("theme = \"dark\""), "{text}");
assert!(!text.contains("dark = true"), "{text}");
}
#[test]
fn missing_schema_version_is_treated_as_current() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "[general]\nlanguage = \"pt\"\n");
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.migrated_from, None);
assert!(!store.is_read_only(), "{report:?}");
assert_eq!(store.get().general.language, "pt");
store.update(|p| p.general.autosave_minutes = 9);
store.flush().unwrap();
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
}
fn parse_legacy_json(bytes: &[u8]) -> Result<Prefs, String> {
// Stand-in for an app's real JSON parser: `{"lang":"de","dark":true}`.
let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
let mut prefs = Prefs::default();
if text.contains("\"dark\":true") {
prefs.appearance.theme = Theme::Dark;
}
if let Some(start) = text.find("\"lang\":\"") {
let rest = &text[start + 8..];
prefs.general.language = rest[..rest.find('"').ok_or("unterminated")?].to_owned();
}
Ok(prefs)
}
fn open_with_legacy(dir: &std::path::Path, files: Vec<LegacyFile<Prefs>>) -> (SettingsStore<Prefs>, craft_settings::LoadReport) {
SettingsStore::open_with(
OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(empty_probe()).debounce(Duration::from_millis(100)).legacy(files),
)
}
#[test]
fn legacy_file_is_imported_on_first_run_and_left_in_place() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("prefs.json");
std::fs::write(&old, r#"{"lang":"de","dark":true}"#).unwrap();
let (mut store, report) = open_with_legacy(tmp.path(), vec![LegacyFile { path: old.clone(), parse: parse_legacy_json }]);
assert_eq!(report.source, Source::Legacy(old.clone()));
assert_eq!(store.get().general.language, "de");
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert!(store.is_dirty());
store.flush().unwrap();
assert!(old.exists(), "the old file is copied, not removed, so a rollback still works");
assert_eq!(std::fs::read_to_string(&old).unwrap(), r#"{"lang":"de","dark":true}"#);
assert!(read_config(tmp.path()).contains("language = \"de\""));
}
#[test]
fn legacy_import_is_skipped_once_config_exists() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("prefs.json");
std::fs::write(&old, r#"{"lang":"de"}"#).unwrap();
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"it\"\n");
let (store, report) = open_with_legacy(tmp.path(), vec![LegacyFile { path: old, parse: parse_legacy_json }]);
assert_eq!(report.source, Source::File);
assert_eq!(store.get().general.language, "it");
}
#[test]
fn first_legacy_file_that_parses_wins() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nope.json");
let bad = tmp.path().join("bad.json");
std::fs::write(&bad, [0xff, 0xfe]).unwrap();
let good = tmp.path().join("good.json");
std::fs::write(&good, r#"{"lang":"nl"}"#).unwrap();
let files = vec![
LegacyFile { path: missing, parse: parse_legacy_json },
LegacyFile { path: bad, parse: parse_legacy_json },
LegacyFile { path: good.clone(), parse: parse_legacy_json },
];
let (store, report) = open_with_legacy(tmp.path(), files);
assert_eq!(report.source, Source::Legacy(good));
assert_eq!(store.get().general.language, "nl");
}
+143
View File
@@ -0,0 +1,143 @@
mod common;
use std::path::PathBuf;
use common::{Prefs, empty_probe, open_in, probe_with_env};
use craft_settings::paths::{Location, Portable, Probe, Rule, env_var_name, resolve};
use craft_settings::{OpenOptions, Settings, SettingsStore};
#[test]
fn env_var_name_uppercases_and_replaces_punctuation() {
assert_eq!(env_var_name("photocraft"), "PHOTOCRAFT_CONFIG_DIR");
assert_eq!(env_var_name("photo-craft"), "PHOTO_CRAFT_CONFIG_DIR");
}
#[test]
fn override_wins_over_everything() {
let probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env");
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Dir(PathBuf::from("/explicit")), &probe);
assert_eq!(r.dir, Some(PathBuf::from("/explicit")));
assert_eq!(r.rule, Rule::Override);
}
#[test]
fn env_var_wins_over_portable_and_platform() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let mut probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env");
probe.exe_dir = Some(tmp.path().to_path_buf());
probe.platform_config_dir = Some(PathBuf::from("/platform"));
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.dir, Some(PathBuf::from("/from/env")));
assert_eq!(r.rule, Rule::EnvVar("TESTCRAFT_CONFIG_DIR".into()));
}
#[test]
fn empty_env_var_means_isolated_not_unset() {
let mut probe = probe_with_env("TESTCRAFT_CONFIG_DIR", "");
probe.platform_config_dir = Some(PathBuf::from("/platform"));
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Isolated("TESTCRAFT_CONFIG_DIR".into()));
assert_eq!(r.dir, None, "never falls through to the real profile");
}
#[test]
fn memory_location_has_no_folder() {
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Memory, &probe_with_env("TESTCRAFT_CONFIG_DIR", "/from/env"));
assert_eq!(r.rule, Rule::Memory);
assert_eq!(r.dir, None);
}
#[test]
fn app_supplied_portable_names_are_used() {
const LEGACY: Portable = Portable { markers: &["portable.txt", "PhotoCraft.portable"], dir: "PhotoCraftData" };
for marker in ["portable.txt", "PhotoCraft.portable"] {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(marker), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
let r = resolve("testcraft", &LEGACY, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Portable, "{marker}");
assert_eq!(r.dir, Some(tmp.path().join("PhotoCraftData")), "{marker}");
}
}
#[test]
fn default_marker_is_ignored_when_the_app_uses_its_own() {
const LEGACY: Portable = Portable { markers: &["portable.txt"], dir: "PhotoCraftData" };
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
assert_eq!(resolve("testcraft", &LEGACY, &Location::Auto, &probe).rule, Rule::Platform);
}
#[test]
fn portable_marker_next_to_executable_uses_config_folder_beside_it() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Portable);
assert_eq!(r.dir, Some(tmp.path().join("config")));
}
#[test]
fn no_marker_means_platform_folder() {
let tmp = tempfile::tempdir().unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(tmp.path().to_path_buf()), platform_config_dir: Some(PathBuf::from("/platform")) };
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Platform);
assert_eq!(r.dir, Some(PathBuf::from("/platform/testcraft")));
}
#[cfg(target_os = "macos")]
#[test]
fn portable_marker_is_looked_for_next_to_the_app_bundle() {
let tmp = tempfile::tempdir().unwrap();
let exe_dir = tmp.path().join("Test.app/Contents/MacOS");
std::fs::create_dir_all(&exe_dir).unwrap();
std::fs::write(tmp.path().join("portable"), "").unwrap();
let probe = Probe { env: Box::new(|_| None), exe_dir: Some(exe_dir), platform_config_dir: None };
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &probe);
assert_eq!(r.rule, Rule::Portable);
assert_eq!(r.dir, Some(tmp.path().join("config")));
}
#[test]
fn no_platform_folder_falls_back_to_current_directory() {
let r = resolve("testcraft", &Portable::DEFAULT, &Location::Auto, &empty_probe());
assert_eq!(r.rule, Rule::Fallback);
assert_eq!(r.dir, Some(PathBuf::from("./testcraft")));
}
#[test]
fn report_names_the_rule_and_folder() {
let tmp = tempfile::tempdir().unwrap();
let (_store, report) = open_in(tmp.path());
assert_eq!(report.dir.as_deref(), Some(tmp.path()));
assert_eq!(report.rule, "override");
assert!(!report.has_problems(), "{report:?}");
}
#[test]
fn open_creates_the_folder() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("nested").join(Prefs::APP_ID);
let (store, report) = SettingsStore::<Prefs>::open_with(OpenOptions::new(Location::Dir(dir.clone())).probe(empty_probe()));
assert!(dir.is_dir());
assert!(!store.is_read_only(), "{report:?}");
}
#[cfg(unix)]
#[test]
fn unwritable_folder_is_reported_at_open_not_first_save() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ro");
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let (mut store, report) = open_in(&dir);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(store.is_read_only());
assert!(matches!(report.read_only, Some(craft_settings::ReadOnlyReason::DirNotWritable(_))), "{report:?}");
assert!(matches!(store.flush(), Err(craft_settings::SettingsError::ReadOnly(_))));
}
+180
View File
@@ -0,0 +1,180 @@
mod common;
use common::{Theme, config_path, open_in, read_config, write_config};
use craft_settings::{ReadOnlyReason, SettingsError, Source};
#[test]
fn syntax_error_moves_the_file_aside_and_uses_defaults() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"de\"\n");
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::Defaults);
assert!(!store.is_read_only());
let moved = report.broken_moved_to.clone().expect("moved aside");
assert!(moved.file_name().unwrap().to_str().unwrap().starts_with("config.broken-"), "{moved:?}");
assert!(moved.exists());
assert!(!config_path(tmp.path()).exists());
assert_eq!(store.get().general.language, "en");
store.flush().unwrap();
assert!(moved.exists(), "the damaged copy is never deleted");
assert!(read_config(tmp.path()).starts_with("schema_version = 2\n"));
}
#[test]
fn two_broken_files_get_different_names() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "not = [toml");
let (_, first) = open_in(tmp.path());
write_config(tmp.path(), "still = [broken");
let (_, second) = open_in(tmp.path());
assert_ne!(first.broken_moved_to, second.broken_moved_to);
assert!(second.broken_moved_to.unwrap().exists());
}
#[test]
fn bad_value_only_resets_its_section() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general]\nlanguage = \"de\"\n\n[appearance]\ntheme = \"sepia\"\nui_scale = 2.0\n");
let (store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::File);
assert!(report.broken_moved_to.is_none());
assert!(!store.is_read_only());
assert_eq!(report.section_errors.len(), 1, "{report:?}");
assert_eq!(report.section_errors[0].section, "appearance");
assert_eq!(store.get().general.language, "de", "the good section survives");
assert_eq!(store.get().appearance.theme, Theme::Light, "the bad section falls back to defaults");
assert!(config_path(tmp.path()).exists(), "the file is not moved aside");
}
#[test]
fn newer_schema_opens_read_only_and_never_overwrites() {
let tmp = tempfile::tempdir().unwrap();
let original = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), original);
let (mut store, report) = open_in(tmp.path());
assert!(matches!(report.read_only, Some(ReadOnlyReason::NewerSchema { file: 9, app: 2 })), "{report:?}");
assert_eq!(store.get().general.language, "de", "values are still shown");
store.update(|p| p.general.language = "fr".into());
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(tmp.path()), original);
}
#[test]
fn failed_migration_opens_read_only_and_leaves_the_file() {
let tmp = tempfile::tempdir().unwrap();
let original = "schema_version = 0\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), original);
let (mut store, report) = open_in(tmp.path());
assert!(matches!(report.read_only, Some(ReadOnlyReason::Migration(_))), "{report:?}");
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(tmp.path()), original);
}
#[cfg(unix)]
#[test]
fn damaged_file_that_cannot_be_moved_is_left_alone() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "not = [toml");
// The folder passes the write probe only while it is writable, so open first, then lock it.
let dir = tmp.path();
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let (mut store, report) = open_in(dir);
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(store.is_read_only(), "{report:?}");
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert_eq!(read_config(dir), "not = [toml");
}
// --- Review on #22: the backup must never be replaced by a damaged or emptied file. ---
#[test]
fn file_damaged_while_running_is_moved_aside_and_backup_kept() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
assert!(good_backup.contains("language = \"fr\""));
// A hand edit with a typo while the app is running.
write_config(tmp.path(), "schema_version = 2\n[general\nlanguage = \"it\"\n");
store.update(|p| p.general.language = "pt".into());
store.flush().unwrap();
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup, "backup untouched");
let moved = store.take_moved_aside();
assert_eq!(moved.len(), 1);
assert_eq!(std::fs::read_to_string(&moved[0]).unwrap(), "schema_version = 2\n[general\nlanguage = \"it\"\n", "the user's edit is kept");
assert!(read_config(tmp.path()).contains("language = \"pt\""));
assert!(store.take_moved_aside().is_empty(), "drained");
}
#[test]
fn emptied_file_does_not_replace_the_backup() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let good_backup = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
write_config(tmp.path(), "");
store.update(|p| p.general.language = "pt".into());
store.flush().unwrap();
assert_eq!(std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap(), good_backup);
assert!(read_config(tmp.path()).contains("language = \"pt\""));
}
#[test]
fn newer_build_writing_while_running_is_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
let newer = "schema_version = 9\n[general]\nlanguage = \"de\"\n";
write_config(tmp.path(), newer);
store.update(|p| p.general.language = "fr".into());
assert!(matches!(store.flush(), Err(SettingsError::ReadOnly(_))));
assert!(store.is_read_only());
assert_eq!(read_config(tmp.path()), newer);
}
// --- Review on #22: a failed migration must not leave half-migrated values in memory. ---
#[cfg(unix)]
#[test]
fn failed_migration_in_an_unwritable_folder_shows_defaults() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
// Version 0 has no migration step, so upgrading fails.
write_config(tmp.path(), "schema_version = 0\n[general]\nlanguage = \"de\"\n");
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let (store, report) = open_in(tmp.path());
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(matches!(report.read_only, Some(ReadOnlyReason::DirNotWritable(_))), "{report:?}");
assert_eq!(store.get().general.language, "en", "defaults, not half-migrated values");
assert_eq!(report.source, Source::Defaults);
}
// --- Review on #22 (skylartaylor): an outside file whose migration fails must not be overwritten. ---
#[test]
fn outside_file_with_failing_migration_is_not_overwritten() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
// Another writer leaves a file at a version this build cannot migrate (0 has no step).
let external = "schema_version = 0\n[general]\nlanguage = 'external'\n";
write_config(tmp.path(), external);
store.update(|p| p.general.language = "local".into());
let result = store.flush();
assert!(matches!(result, Err(SettingsError::ReadOnly(_))), "{result:?}");
assert!(store.is_read_only());
assert_eq!(read_config(tmp.path()), external, "the outside file is left untouched");
assert!(matches!(store.tick(std::time::Instant::now()).status, craft_settings::SaveStatus::Idle), "no retries once read-only");
}
+284
View File
@@ -0,0 +1,284 @@
mod common;
use std::time::{Duration, Instant};
use common::{Theme, config_path, open_in, read_config, write_config};
use craft_settings::{SaveStatus, Source};
#[test]
fn first_run_gives_defaults_and_writes_the_file_on_flush() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::Defaults);
assert_eq!(store.get().general.language, "en");
assert!(!config_path(tmp.path()).exists());
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.starts_with("schema_version = 2\n"), "{text}");
assert!(text.contains("[general]"), "{text}");
assert!(text.contains("[appearance]"), "{text}");
}
#[test]
fn round_trip() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
assert!(store.update(|p| {
p.appearance.theme = Theme::Dark;
p.general.recent = vec!["a.craft".into(), "b.craft".into()];
}));
store.flush().unwrap();
let (store, report) = open_in(tmp.path());
assert_eq!(report.source, Source::File);
assert_eq!(store.get().appearance.theme, Theme::Dark);
assert_eq!(store.get().general.recent, vec!["a.craft", "b.craft"]);
}
#[test]
fn comments_unknown_keys_and_order_survive_a_save() {
let tmp = tempfile::tempdir().unwrap();
write_config(
tmp.path(),
"schema_version = 2\n\n# my notes\n[appearance]\nui_scale = 1.5 # big\nfuture_key = \"kept\"\n\n[general]\nlanguage = \"de\"\n\n[plugins]\nenabled = true\n",
);
let (mut store, report) = open_in(tmp.path());
assert!(report.section_errors.is_empty(), "{report:?}");
assert_eq!(store.get().general.language, "de");
store.update(|p| p.appearance.theme = Theme::Dark);
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.contains("# my notes"), "{text}");
assert!(text.contains("ui_scale = 1.5 # big"), "{text}");
assert!(text.contains("future_key = \"kept\""), "{text}");
assert!(text.contains("[plugins]\nenabled = true"), "{text}");
assert!(text.contains("theme = \"dark\""), "{text}");
assert!(text.find("[appearance]").unwrap() < text.find("[general]").unwrap(), "section order changed:\n{text}");
}
#[test]
fn arrays_are_replaced_whole() {
let tmp = tempfile::tempdir().unwrap();
write_config(tmp.path(), "schema_version = 2\n[general]\nrecent = [\"old1\", \"old2\", \"old3\"]\n");
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.recent = vec!["new".into()]);
store.flush().unwrap();
assert!(read_config(tmp.path()).contains("recent = [\"new\"]"));
}
#[test]
fn update_with_no_change_is_not_dirty() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
assert!(!store.update(|p| p.general.autosave_minutes = 5));
assert!(!store.is_dirty());
assert!(store.update(|p| p.general.autosave_minutes = 6));
assert!(store.is_dirty());
}
#[test]
fn tick_waits_for_the_debounce_and_reports_the_deadline() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
let t0 = Instant::now();
assert!(matches!(store.tick(t0).status, SaveStatus::Idle));
store.update_at(t0, |p| p.appearance.theme = Theme::Dark);
let r = store.tick(t0);
assert!(matches!(r.status, SaveStatus::Pending));
assert_eq!(r.next_deadline, Some(t0 + Duration::from_millis(100)));
assert!(!config_path(tmp.path()).exists());
let r = store.tick(t0 + Duration::from_millis(100));
assert!(matches!(r.status, SaveStatus::Saved), "{r:?}");
assert_eq!(r.next_deadline, None);
assert!(read_config(tmp.path()).contains("theme = \"dark\""));
assert!(matches!(store.tick(t0 + Duration::from_millis(200)).status, SaveStatus::Idle));
}
#[test]
fn further_changes_push_the_save_back() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
let t0 = Instant::now();
store.update_at(t0, |p| p.general.autosave_minutes = 1);
store.update_at(t0 + Duration::from_millis(80), |p| p.general.autosave_minutes = 2);
let r = store.tick(t0 + Duration::from_millis(100));
assert!(matches!(r.status, SaveStatus::Pending));
assert_eq!(r.next_deadline, Some(t0 + Duration::from_millis(180)));
}
#[test]
fn failed_save_is_retried_with_backoff() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
// A folder where the file should be makes the write fail.
std::fs::create_dir(config_path(tmp.path())).unwrap();
let t0 = Instant::now();
store.update_at(t0, |p| p.appearance.theme = Theme::Dark);
let r = store.tick(t0 + Duration::from_millis(100));
assert!(matches!(r.status, SaveStatus::Failed(_)), "{r:?}");
let first_retry = t0 + Duration::from_millis(100) + Duration::from_secs(1);
assert_eq!(r.next_deadline, Some(first_retry));
assert!(matches!(store.tick(first_retry - Duration::from_millis(1)).status, SaveStatus::Pending));
let r = store.tick(first_retry);
assert!(matches!(r.status, SaveStatus::Failed(_)));
assert_eq!(r.next_deadline, Some(first_retry + Duration::from_secs(2)));
std::fs::remove_dir(config_path(tmp.path())).unwrap();
let r = store.tick(first_retry + Duration::from_secs(2));
assert!(matches!(r.status, SaveStatus::Saved), "{r:?}");
assert!(read_config(tmp.path()).contains("theme = \"dark\""));
}
#[test]
fn retry_delay_doubles_and_caps() {
use craft_settings::store::retry_delay;
assert_eq!(retry_delay(1), Duration::from_secs(1));
assert_eq!(retry_delay(2), Duration::from_secs(2));
assert_eq!(retry_delay(3), Duration::from_secs(4));
assert_eq!(retry_delay(6), Duration::from_secs(30));
assert_eq!(retry_delay(40), Duration::from_secs(30));
}
#[test]
fn backup_holds_the_previous_version() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
store.update(|p| p.general.language = "es".into());
store.flush().unwrap();
let bak = std::fs::read_to_string(tmp.path().join("config.toml.bak")).unwrap();
assert!(bak.contains("language = \"fr\""), "{bak}");
assert!(read_config(tmp.path()).contains("language = \"es\""));
}
#[test]
fn outside_edits_between_load_and_save_are_kept() {
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
store.update(|p| p.general.language = "fr".into());
// Another instance (or the user) edits the file meanwhile.
std::thread::sleep(Duration::from_millis(20));
let edited = read_config(tmp.path()).replace("[general]", "# added by hand\n[other]\nx = 1\n\n[general]");
write_config(tmp.path(), &edited);
let later = std::time::SystemTime::now() + Duration::from_secs(2);
std::fs::File::options().write(true).open(config_path(tmp.path())).unwrap().set_modified(later).unwrap();
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.contains("# added by hand"), "{text}");
assert!(text.contains("[other]\nx = 1"), "{text}");
assert!(text.contains("language = \"fr\""), "{text}");
}
#[cfg(unix)]
#[test]
fn new_file_is_owner_only_and_keeps_its_mode_on_replace() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
let mode = std::fs::metadata(config_path(tmp.path())).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o644)).unwrap();
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
let mode = std::fs::metadata(config_path(tmp.path())).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o644, "a user's deliberate mode change is kept");
}
#[cfg(unix)]
#[test]
fn read_only_file_makes_the_save_fail_visibly() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o400)).unwrap();
store.update(|p| p.general.language = "fr".into());
let r = store.tick(Instant::now() + Duration::from_secs(1));
assert!(matches!(r.status, SaveStatus::Failed(craft_settings::SettingsError::Io(_))), "{r:?}");
assert!(!read_config(tmp.path()).contains("language = \"fr\""));
}
#[test]
fn outside_edit_with_the_same_mtime_is_still_seen() {
// Coarse timestamps (FAT, HFS+, network shares) can leave mtime unchanged after an edit.
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
let mtime = std::fs::metadata(config_path(tmp.path())).unwrap().modified().unwrap();
let edited = read_config(tmp.path()).replace("[general]", "# same second\n[general]");
write_config(tmp.path(), &edited);
std::fs::File::options().write(true).open(config_path(tmp.path())).unwrap().set_modified(mtime).unwrap();
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
let text = read_config(tmp.path());
assert!(text.contains("# same second"), "{text}");
assert!(text.contains("language = \"fr\""), "{text}");
}
// --- Review on #22 (skylartaylor): the backup must never be more readable than config.toml. ---
#[cfg(unix)]
fn mode(path: &std::path::Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[cfg(unix)]
#[test]
fn new_backup_is_as_private_as_the_config() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
store.update(|p| p.general.language = "private".into());
store.flush().unwrap();
assert_eq!(mode(&config_path(tmp.path())), 0o600);
assert_eq!(mode(&tmp.path().join("config.toml.bak")), 0o600);
}
#[cfg(unix)]
#[test]
fn existing_broader_backup_is_tightened() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
// A backup left behind by an older build, readable by everyone.
let bak = tmp.path().join("config.toml.bak");
std::fs::write(&bak, b"old backup").unwrap();
std::fs::set_permissions(&bak, std::fs::Permissions::from_mode(0o664)).unwrap();
store.update(|p| p.general.language = "private".into());
store.flush().unwrap();
assert_eq!(mode(&bak), mode(&config_path(tmp.path())), "backup must not be more readable than config.toml");
assert_eq!(mode(&bak), 0o600);
}
#[cfg(unix)]
#[test]
fn backup_follows_a_mode_the_user_chose_for_the_config() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let (mut store, _) = open_in(tmp.path());
store.flush().unwrap();
std::fs::set_permissions(config_path(tmp.path()), std::fs::Permissions::from_mode(0o640)).unwrap();
store.update(|p| p.general.language = "fr".into());
store.flush().unwrap();
assert_eq!(mode(&tmp.path().join("config.toml.bak")), 0o640);
}
+102
View File
@@ -0,0 +1,102 @@
# Settings storage across the Craft apps
How every Craft app stores its settings, and what goes where. The shared code is
[`craft-settings`](../crates/settings/README.md); this page is the policy it implements.
Discussion: [storytold/craft-libs#12](https://github.com/storytold/craft-libs/issues/12).
## What goes where
| Kind | Where | Why |
|---|---|---|
| Settings people might edit | `config.toml` in the config folder | One readable file with comments, atomic saves, backup, recovery, migrations |
| Window and UI state | `ui.ron` / `app.ron` beside `config.toml` | Changes every session; not worth comments or hand edits |
| Caches and models | Platform cache folder (`paths::cache_dir`); `%LOCALAPPDATA%` on Windows so it does not roam | Disposable, large |
| Recovery and autosave files | Platform data folder (`paths::data_dir`) | Not settings; must survive a settings reset |
| Settings that belong to a library or project (LightCraft) | Inside that folder, via `SettingsStore::open_at` | Travels with the library |
| API keys and tokens | The OS keychain | Never in a plain-text file |
## The config folder
First match wins. The store reports which rule applied, so apps can show the path in their
about or settings screen.
1. An override passed by the app, such as a `--config-dir` flag
2. `<APP>_CONFIG_DIR`, for example `PHOTOCRAFT_CONFIG_DIR` (one per app, so redirecting one
app does not move the others). Set but **empty** means isolated: nothing is read or written
anywhere, the same as DesignCraft's existing behaviour, so tests and agents can never touch a
user's real settings
3. Portable mode: a marker file next to the executable (next to the `.app` on macOS, since
writing inside a signed bundle breaks its signature). The default is a file named `portable`
with settings in `config/` beside it; apps that already ship portable builds keep their own
names (PhotoCraft: `portable.txt` or `PhotoCraft.portable`, folder `PhotoCraftData`)
4. The platform default:
Apps can also open a store with `Location::Memory` (for a switch like `DESIGNCRAFT_NO_PREFS`):
settings work in memory and nothing touches the disk.
| Platform | Folder |
|---|---|
| Linux / BSD | `$XDG_CONFIG_HOME/<app>/`, or `~/.config/<app>/` if unset |
| macOS | `~/Library/Application Support/<app>/` |
| Windows | `%APPDATA%\<app>\` (Roaming) |
`<app>` is the app's lowercase id (`photocraft`, `designcraft`, …) on every platform, macOS
included (decided in #12). Existing Linux folder names do not change; the Launcher moves from
`ai.storyteller.artcraft-launcher` to `artcraft-launcher`.
If the folder cannot be created or written (a portable marker in `/Applications`, an AppImage
mount), the store opens read-only and says so at startup.
## Files in the folder
- `config.toml`: starts with `schema_version = N`, then one `[section]` per settings area.
Written on first launch so people can find it. `0600` on Unix.
- `config.toml.bak`: the previous version, refreshed before every save.
- `config.broken-<unix-ts>.toml`: a file that was not valid TOML, moved aside and never deleted.
## Saving and recovery
- Saves happen during use, half a second after the last change, atomically through
`craft-atomic`. A failed save leaves the old file intact and is retried with backoff; the app
shows the error.
- A file with a TOML syntax error is moved aside and defaults are used. A file that parses but
has a value that no longer fits (a renamed theme, a typo in a hand edit) is not treated as
broken: only that `[section]` falls back to defaults and the app is told which one.
- A file written by a newer build is shown but never overwritten.
- Comments, key order and keys the app does not know survive saves. If two instances of an app
are open, each re-reads the file before writing so those survive; known keys are
last-writer-wins.
## Migration
- Schema changes: `Settings::migrate` upgrades the document one version at a time before it is
parsed. A failed step leaves the file untouched and the store read-only.
- Older per-app files (`prefs.json`, `ui.json`, `*.ron`): imported on the first run that finds no
`config.toml`, using the app's existing parser. The old file is **left in place**, so rolling
back to an older build keeps working; apps can remove it a few releases later.
## Where the apps are today
From the audit in #12 (Linux defaults, from source; overrides and portable mode change them).
Before an app moves over, check where it saves on macOS and Windows so the importer finds the old
files there too.
| App | Today | Moving to `craft-settings` |
|---|---|---|
| PhotoCraft | `~/.config/photocraft/preferences.json` + eframe `ui.ron`; atomic saves with retries; portable via `portable.txt`/`PhotoCraft.portable` → `PhotoCraftData/` | Import `preferences.json`; keep `ui.ron`; keep its portable names via `PORTABLE_MARKERS`/`PORTABLE_DIR` |
| DesignCraft | `~/.config/designcraft/prefs.json` + `ui.json`; saves on exit, ignores failures; empty `DESIGNCRAFT_CONFIG_DIR` and `DESIGNCRAFT_NO_PREFS` touch nothing | Import `prefs.json`; keep `ui.json`; gain save-during-use and error reporting; `DESIGNCRAFT_NO_PREFS` → `Location::Memory` |
| EffectCraft | `~/.config/effectcraft/prefs.json`, `shortcuts.json`, `script_settings.json`; atomic | Import all three into sections of one `config.toml` |
| FilmCraft | `~/.local/share/filmcraft/preferences.json`; atomic | Import from the data folder; settings move to the config folder |
| GridCraft | `~/.config/gridcraft/prefs.json` + `ui.json`; saves on exit, ignores failures | Same as DesignCraft |
| LightCraft | global `ui.json`; library `prefs.json`, `presets.json`, `view.json`; atomic, backups, overwrite protection | Global settings via `open`; library settings via `open_at(library_dir)` |
| VectorCraft | `~/.config/vectorcraft/ui.json` with engine preferences inside; atomic, ignores failures | Split engine preferences out into `config.toml`; keep UI state in `ui.json` |
| ArtCraft Launcher | `~/.local/share/ai.storyteller.artcraft-launcher/app.ron` (eframe) | Import from `app.ron`; needs its own tick timer while in the tray |
Adoption order: PhotoCraft and DesignCraft first (closest to and furthest from the target
behaviour), then the rest.
## Not yet
- Reloading settings when the file changes on disk while the app runs
- Removing imported legacy files
- Settings on the web (`wasm32`), where only the trait, document helpers and error types exist