Files
DarkRoom/ui/dr-ui/src/settings_store.rs
T
dtourolleandClaude Opus 5 ab0ef6a26d Say which requirements the code was already satisfying
Thirteen requirements were surveyed as built but untagged. Eight of them
were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and
NFR-SEC-3. Each was read against its full text in requirements.md and
against the code before the tag was added, because a tag that is wrong is
worse than an absent one — it turns a visible gap into an invisible one.

The five that were refused, and why, because the reasoning is the part
worth keeping:

R2 carries "(figure TBD)" in its own acceptance criterion and asks for a
stated prefetch margin and cache-hit rate; neither figure exists anywhere
in the tree and neither quantity is measured, while TD-2 and TD-3 both
describe the thumbnail path falling short of it.

R5 asks for three things and the code does one. The display pipeline does
run at viewport resolution, but "only visible tiles are computed" and
"panning recomputes only newly exposed tiles" need a tile scheduler that
does not exist — and frame_budget.rs currently argues for striking tiled
computation from the interactive path rather than building it.

FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can
be added without changing callers. What exists is free functions over
&[u8]. That meets the requirement's stated *purpose* — the same decoder
serves a local file, a SAF document and a byte range, which is exactly why
it takes bytes — but there is no trait and no second implementation seam,
so the requirement should probably be amended rather than tagged.

NFR-ARCH-1 asks for named executors with stated thread counts.
architecture.md §7.1 states the table; nothing implements it. Workers are
twenty-odd ad-hoc std::thread::spawn sites, each building its own
one-worker tokio runtime, with no decode pool, no GPU-submit executor and
no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an
outcome with no stated means", and that is still true.

NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence
has no module to tag. A tag would point at nothing.

NFR-OPS-3 was the closest call of the eight taken. The store is single,
separate from the catalog, survives a catalog rebuild and does not sync
between devices; it has no version *field*, deliberately, and
settings.rs argues why and names the condition that would need one. The
substance is met and the reasoning is recorded where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:23:16 +02:00

234 lines
8.5 KiB
Rust

//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3
//! Reads and writes `settings.json` beside the session config.
//!
//! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore)
//! rather than an extension of it. Two files, two lifetimes: signing out
//! forgets a session and must not discard a cache budget, and resetting
//! preferences must not revoke a credential. Merging them would couple those.
//!
//! There is no `version` field here, unlike `sessions.json`. Every field in
//! [`Settings`] is `#[serde(default)]`, so an older file is missing fields
//! rather than wrong about them, and a newer file read by an older build
//! ignores what it does not know. That covers additive change, which is the
//! only kind this record has had; a field whose *meaning* changes will need a
//! version, and that is the point to add one.
use std::path::{Path, PathBuf};
use dr_types::Settings;
/// Loads and saves device preferences.
pub struct SettingsStore {
path: PathBuf,
}
impl SettingsStore {
/// Open the store at the platform config location.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/settings.json`, falling back to
/// `~/.config` (FR-PLAT-LIN-1) — the same resolution `SessionStore` does,
/// so the two files sit together and a user backing up one takes both.
pub fn open() -> Self {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom");
Self::open_at(dir.join("settings.json"))
}
/// Open at an explicit path — for tests, and for a non-default location.
pub fn open_at(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
/// The stored settings, or the defaults.
///
/// A missing file is a first run, not a failure. An *unparseable* file is
/// also answered with defaults rather than an error, because the
/// alternative is an app that will not start until the user hand-edits
/// JSON — and the file is rewritten whole on the next save, so the damage
/// does not persist. The parse failure is logged so it is not silent.
pub fn load(&self) -> Settings {
let mut settings = match std::fs::read_to_string(&self.path) {
Ok(text) => match serde_json::from_str::<Settings>(&text) {
Ok(s) => s,
Err(e) => {
log::warn!(
"{} is not readable settings ({e}); using defaults",
self.path.display()
);
Settings::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Settings::default(),
Err(e) => {
log::warn!("reading {}: {e}; using defaults", self.path.display());
Settings::default()
}
};
// The file is hand-editable, so nothing downstream may assume a sane
// range until this has run.
settings.sanitise();
settings
}
/// Persist settings, replacing whatever was there.
///
/// A whole-file write rather than a merge: this record is the complete set
/// of preferences and the caller is holding the copy the user just edited.
/// Merging would let a field the page does not yet expose resurrect an old
/// value the user thought they had changed.
pub fn save(&self, settings: &Settings) -> Result<(), SettingsError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(settings)?;
// Write and rename, so an interrupted save cannot truncate the
// existing file — the same discipline `SessionStore` uses. Settings are
// saved on every field edit, which makes the interrupted-write window
// something the user actually meets rather than a theoretical one.
let tmp = self.path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SettingsError {
#[error("settings io: {0}")]
Io(#[from] std::io::Error),
#[error("settings format: {0}")]
Serde(#[from] serde_json::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::{settings::budget, ExportFormat};
fn tempdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-settings-test-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn store(name: &str) -> (SettingsStore, PathBuf) {
let dir = tempdir(name);
(SettingsStore::open_at(dir.join("settings.json")), dir)
}
#[test]
fn a_first_run_gets_the_defaults() {
let (store, _dir) = store("first-run");
assert!(!store.path().exists());
assert_eq!(store.load(), Settings::default());
}
#[test]
fn settings_survive_a_round_trip() {
let (store, _dir) = store("round-trip");
let mut settings = Settings::default();
settings.export.format = ExportFormat::Tiff16;
settings.export.quality = 72;
settings.cache.original_budget_bytes = budget::from_gb("16");
settings.cache.keep_opened_originals = false;
store.save(&settings).unwrap();
assert_eq!(store.load(), settings);
}
#[test]
fn an_unlimited_budget_survives_the_file() {
// `None` and `Some(0)` are different settings; a serialiser that
// flattened one into the other would turn "keep everything" into
// "keep nothing" — the worst possible confusion of the two.
let (store, _dir) = store("unlimited");
let mut settings = Settings::default();
settings.cache.original_budget_bytes = None;
store.save(&settings).unwrap();
assert_eq!(store.load().cache.original_budget_bytes, None);
}
#[test]
fn saving_creates_the_config_directory() {
// A first save on a fresh machine has no `~/.config/darkroom` yet.
let dir = tempdir("mkdir");
let store = SettingsStore::open_at(dir.join("nested").join("settings.json"));
store.save(&Settings::default()).unwrap();
assert!(store.path().exists());
}
#[test]
fn a_corrupt_file_yields_defaults_rather_than_failing() {
// An app that will not start until the user hand-edits JSON is worse
// than one that forgets a preference.
let (store, _dir) = store("corrupt");
std::fs::write(store.path(), "{ this is not json").unwrap();
assert_eq!(store.load(), Settings::default());
}
#[test]
fn a_file_from_an_older_build_keeps_what_it_does_say() {
// Additive change is the case `serde(default)` covers, and the point of
// having no version field. The named value must survive.
let (store, _dir) = store("older");
std::fs::write(store.path(), r#"{"export":{"quality":55}}"#).unwrap();
let loaded = store.load();
assert_eq!(loaded.export.quality, 55);
assert_eq!(loaded.cache, dr_types::CacheSettings::default());
}
#[test]
fn load_sanitises_a_hand_edited_file() {
let (store, _dir) = store("sanitise");
std::fs::write(store.path(), r#"{"export":{"quality":250}}"#).unwrap();
assert_eq!(store.load().export.quality, 100);
}
#[test]
fn a_save_replaces_rather_than_merging() {
let (store, _dir) = store("replace");
let mut first = Settings::default();
first.export.quality = 50;
store.save(&first).unwrap();
// The defaults again: quality must go back to 90, not stay at 50.
store.save(&Settings::default()).unwrap();
assert_eq!(store.load().export.quality, 90);
}
#[test]
fn no_temporary_file_is_left_behind() {
let (store, dir) = store("no-temp");
store.save(&Settings::default()).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "left {leftovers:?} behind");
}
}