mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-01 16:24:51 +00:00
Add shared config.toml storage while keeping settings types, defaults and UI policy in each app. Includes isolated in-memory operation, configurable portable paths, atomic saves with private backups, damaged-file preservation, schema migrations, and documented best-effort handling of external edits. Validation: native macOS formatting, strict workspace clippy, 172 workspace tests and the craft-settings WASM check. Independent backup-permission and failed external-migration repros fail before the fixes and pass afterward. Hosted CI: all seven UI and printing jobs pass across macOS, Linux, Windows and UI WASM. Refs #12
110 lines
4.1 KiB
Rust
110 lines
4.1 KiB
Rust
//! TOML document handling: `schema_version`, per-section parsing and comment-preserving merges.
|
|
|
|
use toml_edit::{DocumentMut, Item, Table, TableLike};
|
|
|
|
use crate::Settings;
|
|
use crate::error::SectionError;
|
|
|
|
/// The key written first in every `config.toml`.
|
|
pub const SCHEMA_VERSION_KEY: &str = "schema_version";
|
|
|
|
/// Parses `text` as TOML, keeping comments and formatting.
|
|
pub fn parse(text: &str) -> Result<DocumentMut, toml_edit::TomlError> {
|
|
text.parse()
|
|
}
|
|
|
|
/// Reads `schema_version`; `None` when the key is missing or not a non-negative integer.
|
|
pub fn schema_version(doc: &DocumentMut) -> Option<u32> {
|
|
doc.get(SCHEMA_VERSION_KEY)?.as_integer().and_then(|v| u32::try_from(v).ok())
|
|
}
|
|
|
|
/// Sets `schema_version`, keeping its position if it already exists.
|
|
pub fn set_schema_version(doc: &mut DocumentMut, version: u32) {
|
|
doc[SCHEMA_VERSION_KEY] = toml_edit::value(i64::from(version));
|
|
}
|
|
|
|
/// An empty document carrying only `schema_version`.
|
|
pub fn new_document(version: u32) -> DocumentMut {
|
|
let mut doc = DocumentMut::new();
|
|
set_schema_version(&mut doc, version);
|
|
doc
|
|
}
|
|
|
|
/// Turns `doc` into `T`, one top-level section at a time.
|
|
///
|
|
/// A section that does not fit `T` is reported and replaced by its default; every other section
|
|
/// is kept. This is what keeps a renamed enum variant from resetting the whole file.
|
|
pub fn deserialize_sections<T: Settings>(doc: &DocumentMut) -> (T, Vec<SectionError>) {
|
|
let defaults = T::default();
|
|
let Ok(mut merged) = toml_edit::ser::to_document(&defaults) else {
|
|
return (defaults, Vec::new());
|
|
};
|
|
let mut errors = Vec::new();
|
|
for (key, item) in doc.iter() {
|
|
if key == SCHEMA_VERSION_KEY {
|
|
continue;
|
|
}
|
|
let mut candidate = merged.clone();
|
|
candidate[key] = item.clone();
|
|
match toml_edit::de::from_document::<T>(candidate.clone()) {
|
|
Ok(_) => merged = candidate,
|
|
Err(e) => errors.push(SectionError { section: key.to_owned(), message: e.to_string() }),
|
|
}
|
|
}
|
|
let value = toml_edit::de::from_document::<T>(merged).unwrap_or(defaults);
|
|
(value, errors)
|
|
}
|
|
|
|
/// Writes `value` into `doc`, keeping comments, key order and keys `value` does not have.
|
|
///
|
|
/// Tables merge key by key; arrays, arrays of tables and scalars are replaced whole.
|
|
pub fn merge_into<T: Settings>(doc: &mut DocumentMut, value: &T) -> Result<(), toml_edit::ser::Error> {
|
|
let mut fresh = toml_edit::ser::to_document(value)?;
|
|
expand_inline_tables(fresh.as_table_mut());
|
|
merge_tables(doc.as_table_mut(), fresh.as_table());
|
|
Ok(())
|
|
}
|
|
|
|
/// Serde writes nested structs as inline tables (`general = { … }`); files are nicer to read
|
|
/// and edit as `[general]` sections, so new sections are converted before merging.
|
|
fn expand_inline_tables(table: &mut Table) {
|
|
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_owned()).collect();
|
|
for key in keys {
|
|
let Some(item) = table.get_mut(&key) else { continue };
|
|
if let Some(inline) = item.as_inline_table() {
|
|
*item = Item::Table(inline.clone().into_table());
|
|
}
|
|
if let Some(sub) = item.as_table_mut() {
|
|
expand_inline_tables(sub);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn merge_tables(into: &mut dyn TableLike, from: &dyn TableLike) {
|
|
for (key, item) in from.iter() {
|
|
match into.get_mut(key) {
|
|
Some(existing) if existing.is_table_like() && item.is_table_like() => {
|
|
if let (Some(existing), Some(fresh)) = (existing.as_table_like_mut(), item.as_table_like()) {
|
|
merge_tables(existing, fresh);
|
|
}
|
|
}
|
|
Some(existing) => {
|
|
// Keep the spacing and trailing comment of the line being replaced.
|
|
let mut fresh = item.clone();
|
|
if let (Some(old), Some(new)) = (existing.as_value(), fresh.as_value_mut()) {
|
|
*new.decor_mut() = old.decor().clone();
|
|
}
|
|
*existing = fresh;
|
|
}
|
|
None => {
|
|
into.insert(key, item.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Renders `doc` as text.
|
|
pub fn render(doc: &DocumentMut) -> String {
|
|
doc.to_string()
|
|
}
|