diff --git a/Cargo.lock b/Cargo.lock index eb2bf1db..7dec3c32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,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" @@ -451,6 +462,27 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[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" @@ -1729,6 +1761,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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" @@ -2032,6 +2070,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" @@ -2522,8 +2570,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", ] @@ -2536,6 +2587,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 = "tracing" version = "0.1.44" diff --git a/Cargo.toml b/Cargo.toml index e02d853d..44d704a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/README.md b/README.md index 0137cfe9..1be832cb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " 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::::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. `_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//` or `~/.config//` on Linux, + `~/Library/Application Support//` on macOS, `%APPDATA%\\` 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-.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-.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. diff --git a/crates/settings/src/document.rs b/crates/settings/src/document.rs new file mode 100644 index 00000000..d5c80025 --- /dev/null +++ b/crates/settings/src/document.rs @@ -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 { + text.parse() +} + +/// Reads `schema_version`; `None` when the key is missing or not a non-negative integer. +pub fn schema_version(doc: &DocumentMut) -> Option { + 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(doc: &DocumentMut) -> (T, Vec) { + 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::(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::(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(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 = 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() +} diff --git a/crates/settings/src/error.rs b/crates/settings/src/error.rs new file mode 100644 index 00000000..b8b97ee7 --- /dev/null +++ b/crates/settings/src/error.rs @@ -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) -> 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, + /// 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, + /// Sections whose values were replaced by defaults. + pub section_errors: Vec, + /// Set when the file was upgraded from an older `schema_version`. + pub migrated_from: Option, + /// Set when the store will not save. + pub read_only: Option, +} + +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 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, +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs new file mode 100644 index 00000000..23227277 --- /dev/null +++ b/crates/settings/src/lib.rs @@ -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::::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 `_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(()) + } +} diff --git a/crates/settings/src/migrate.rs b/crates/settings/src/migrate.rs new file mode 100644 index 00000000..c12168ad --- /dev/null +++ b/crates/settings/src/migrate.rs @@ -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(doc: &mut DocumentMut) -> Result { + 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 { + /// 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, +} + +impl std::fmt::Debug for LegacyFile { + 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(files: &[LegacyFile]) -> 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())) + }) +} diff --git a/crates/settings/src/paths.rs b/crates/settings/src/paths.rs new file mode 100644 index 00000000..c3131542 --- /dev/null +++ b/crates/settings/src/paths.rs @@ -0,0 +1,170 @@ +//! Choosing the configuration folder. +//! +//! First match wins: an override from the application, the `_CONFIG_DIR` variable, a +//! portable marker next to the executable, then the platform's own config folder. An empty +//! `_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, `_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, + /// Why. + pub rule: Rule, +} + +/// Reads an environment variable by name. +pub type EnvReader = Box Option>; + +/// 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, + /// The platform config folder (`~/.config`, `Application Support`, `%APPDATA%`). + pub platform_config_dir: Option, +} + +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 `_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 { + 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 { + 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 { + dirs::data_local_dir().map(|d| d.join(app_id)) +} diff --git a/crates/settings/src/recovery.rs b/crates/settings/src/recovery.rs new file mode 100644 index 00000000..19daf1c2 --- /dev/null +++ b/crates/settings/src/recovery.rs @@ -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-.toml` beside it and returns the +/// new path. Never overwrites: a name that exists gets a `-` suffix. +pub fn move_aside(path: &Path) -> io::Result { + 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) +} diff --git a/crates/settings/src/store.rs b/crates/settings/src/store.rs new file mode 100644 index 00000000..5ddfbe33 --- /dev/null +++ b/crates/settings/src/store.rs @@ -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 { + /// Where the settings live. + pub location: Location, + /// Older files to import when there is no `config.toml` yet. + pub legacy: Vec>, + /// Quiet period after a change before saving. + pub debounce: Duration, + /// What the folder rules look at. + pub probe: Probe, +} + +impl Default for OpenOptions { + fn default() -> Self { + Self { location: Location::Auto, legacy: Vec::new(), debounce: DEFAULT_DEBOUNCE, probe: Probe::default() } + } +} + +impl OpenOptions { + /// 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>) -> 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 `_CONFIG_DIR`, or [`Location::Memory`]) the store keeps +/// everything in memory: `get`/`update` work as usual, nothing is read or written. +pub struct SettingsStore { + dir: Option, + path: Option, + doc: DocumentMut, + value: T, + read_only: Option, + dirty: bool, + written: bool, + last_change: Option, + debounce: Duration, + failures: u32, + retry_at: Option, + /// 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, + /// Files that were damaged on disk at save time and moved aside; see [`SettingsStore::take_moved_aside`]. + moved_aside: Vec, +} + +impl std::fmt::Debug for SettingsStore { + 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 SettingsStore { + /// 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) -> (Self, LoadReport) { + Self::open(Location::Dir(dir.into())) + } + + /// Opens the store with full options. + pub fn open_with(options: OpenOptions) -> (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 { + 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::(&mut doc) else { + return; + }; + let (value, errors) = document::deserialize_sections::(&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::(&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::(&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, 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::(&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(()) +} diff --git a/crates/settings/tests/common/mod.rs b/crates/settings/tests/common/mod.rs new file mode 100644 index 00000000..a9017e46 --- /dev/null +++ b/crates/settings/tests/common/mod.rs @@ -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, +} + +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, 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() +} diff --git a/crates/settings/tests/memory.rs b/crates/settings/tests/memory.rs new file mode 100644 index 00000000..9075b510 --- /dev/null +++ b/crates/settings/tests/memory.rs @@ -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) { + 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::::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::::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::::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"); +} diff --git a/crates/settings/tests/migrate.rs b/crates/settings/tests/migrate.rs new file mode 100644 index 00000000..5f1178c1 --- /dev/null +++ b/crates/settings/tests/migrate.rs @@ -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 { + // 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>) -> (SettingsStore, 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"); +} diff --git a/crates/settings/tests/paths.rs b/crates/settings/tests/paths.rs new file mode 100644 index 00000000..e0bcde66 --- /dev/null +++ b/crates/settings/tests/paths.rs @@ -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::::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(_)))); +} diff --git a/crates/settings/tests/recovery.rs b/crates/settings/tests/recovery.rs new file mode 100644 index 00000000..4af90bb4 --- /dev/null +++ b/crates/settings/tests/recovery.rs @@ -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"); +} diff --git a/crates/settings/tests/store.rs b/crates/settings/tests/store.rs new file mode 100644 index 00000000..385e9556 --- /dev/null +++ b/crates/settings/tests/store.rs @@ -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); +} diff --git a/docs/settings-storage.md b/docs/settings-storage.md new file mode 100644 index 00000000..f5bf6a52 --- /dev/null +++ b/docs/settings-storage.md @@ -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. `_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//`, or `~/.config//` if unset | +| macOS | `~/Library/Application Support//` | +| Windows | `%APPDATA%\\` (Roaming) | + +`` 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-.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