Files
DarkRoom/ui/dr-ui/src/presets.rs
T
dtourolleandClaude Opus 5 754ab91347 Bring a Lightroom library across, and start with something in the list
Two halves of the same complaint: a preset sheet that opens on "No
presets yet" is homework, and a photographer with ten years of presets
in Lightroom has no way to bring them.

`dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be
mostly a rename rather than a conversion, because Adobe and this
pipeline already agree: exposure is in stops in both, and contrast, the
four recovery controls, clarity, texture, vibrance and saturation are
all ±100 in both. That is not imitation, it is the convention raw
developers converged on — `highlights_shadows.yaml` cites it in as many
words. Only sharpening needed arithmetic, Adobe's 0…150 against our
0…100.

The white balance does not come across, and says so rather than
guessing. Adobe writes absolute Kelvin for a raw file where ours is a
relative nudge from what the camera recorded, so converting needs the
*target image's* as-shot white balance — exactly what a preset cannot
carry, since the same preset lands on a frame shot at 3200K and one shot
at 7000K. A guess would be wrong on most images and invisibly so.

A folder is read as readily as a file, nested, because that is the shape
an exported preset folder is in and importing ninety files one at a time
is asking someone not to bother.

`dr_pipeline::starter` is six presets a first run begins with, written
against this pipeline in its units and deliberately mild — a starting
point, not a caricature. They are seeded when the library *file* does
not exist rather than when the library is empty, so deleting all six
does not hand them back on the next launch.

Both of these name operations, and `ui_names_no_operation` was right to
stop them living in `ui/`. That test exists because the failure is
silent and cumulative, and it caught exactly what it was written for: a
preset called "Punch" is a statement about contrast, clarity and
vibrance, and a table mapping Adobe's vocabulary to ours is a statement
about the pipeline. Neither is a fact about an interface. So the starter
set went into `dr-pipeline`, and the importer into its own crate —
between two walls, since `dr-pipeline` depends on nothing on purpose and
XMP is real XML not worth hand-rolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:00:53 +02:00

1584 lines
58 KiB
Rust

//! TRACES: FR-DEV-6
//! Copying develop settings from one photograph to others.
//!
//! # The three pieces
//!
//! * A [`Clipboard`] — one [`Preset`] held for the life of the window. Not
//! the system clipboard: these are typed values addressed by `op.param`,
//! and putting them on a text clipboard would mean anything that happened
//! to be copied afterwards silently disarmed the paste.
//! * An [`OpenImage`] — where the develop view's edit is *stored*, which is
//! what a copy is taken from and what a paste has to be written back to.
//! * The batch, in [`paste_to_selection`], which never opens an image at all.
//!
//! # Why a paste can reach images that are not open
//!
//! Applying settings to forty frames by opening forty develop sessions would
//! mean forty downloads of a whole RAW file and forty demosaics, to change
//! some numbers. The edit is *data* — [`Preset::amend`] operates on the
//! parameter map a sidecar already stores — so the batch is a read-modify-write
//! per file and never touches a GPU. That is what makes it usable on a phone.
//!
//! The cost is that a batch paste is only visible once the target is opened
//! and its sidecar read, which is the load path this module also provides.
//!
//! # Two stores, because there are two ways an image is opened
//!
//! An image from the library grid lives on the server and its sidecar goes
//! beside it, through the same writer a rating uses. An image named on the
//! command line is a local file with no library behind it, and its sidecar is
//! written beside it on disk. Both are the same document in the same format —
//! only the transport differs, which is why [`Stored`] is an enum over where
//! rather than two notions of what.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use dr_pipeline::{NameError, Preset, PresetLibrary, Scope, Sidecar};
use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::preset_store::PresetStore;
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow, ScopeKind};
/// Where the develop view's current edit is stored.
///
/// `None` is not an error state: an image that failed to decode, or one opened
/// before a library was chosen, has nowhere to persist to and simply does not.
/// Copy still works from it — the graph is in memory either way — and it is
/// only the *saving* that has no destination.
#[derive(Debug, Clone, Default)]
pub enum Stored {
#[default]
Nowhere,
/// A file on this device, named on the command line.
Local(PathBuf),
/// An image in the library. The uuid comes from the catalog so that this
/// device and every other name the same version (FR-NC-8).
Remote { path: String, version_uuid: String },
}
/// Which image the develop view is showing, and where its edit belongs.
pub type OpenImage = Rc<RefCell<Stored>>;
/// The settings clipboard.
///
/// Holds the preset at full scope; the [`Scope`] is applied at paste time from
/// the user's setting, so changing that setting after copying takes effect on
/// the next paste rather than requiring a fresh copy.
#[derive(Default)]
pub struct Clipboard {
preset: RefCell<Option<Preset>>,
}
impl Clipboard {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
}
/// Take a copy of a session's edit.
pub fn copy_from(&self, session: &DevelopSession) {
self.put(session.copy_settings());
}
/// Hold an already-captured preset.
pub fn put(&self, preset: Preset) {
*self.preset.borrow_mut() = Some(preset);
}
/// The held preset, if there is one.
pub fn peek(&self) -> Option<Preset> {
self.preset.borrow().clone()
}
/// Whether anything has been copied.
///
/// True even for a *neutral* copy. Copying an unedited photograph and
/// pasting it onto an edited one is a meaningful action — it clears the
/// target — so "has something been copied" is a different question from
/// "does the copy contain any values", and this is the first.
pub fn is_armed(&self) -> bool {
self.preset.borrow().is_some()
}
/// Whether the held preset carries framing that the current scope drops.
///
/// What the interface uses to explain a setting's effect on *this*
/// clipboard rather than in the abstract.
pub fn holds_framing(&self) -> bool {
self.preset
.borrow()
.as_ref()
.is_some_and(|p| p.touches_framing())
}
/// A short description of what would be pasted, for a button's label.
///
/// Counts operations rather than parameters: the colour mixer alone
/// declares thirty-six, and "36 settings" would say something about the
/// mixer's shape rather than about how much was copied.
pub fn describe(&self, scope: Scope) -> String {
let Some(preset) = self.preset.borrow().clone() else {
return String::new();
};
describe(&preset, scope)
}
}
/// A short description of a preset's contents, for a label.
///
/// Free rather than a method on [`Clipboard`] because the named-preset sheet
/// describes what *saving* would capture, and a second phrasing of the same
/// count is a second thing to keep in step — the sheet saying "3 settings"
/// beside a panel saying "3 adjustments" would read as two different numbers.
pub fn describe(preset: &Preset, scope: Scope) -> String {
match preset.op_count(scope) {
0 => "Neutral".to_string(),
1 => "1 adjustment".to_string(),
n => format!("{n} adjustments"),
}
}
/// The scope a paste should use, from the user's preference.
///
/// One function rather than the setting read at each call site, so what the
/// stored names mean — including what their absence means — is stated once.
///
/// # The migration lives here
///
/// `copy_attributes` is `None` on a device that has only ever run a build with
/// the old boolean, and the answer is derived from that boolean exactly once:
/// a photographer who had asked for the crop to travel keeps getting the crop.
/// Writing the derived set back is the *caller's* business — see
/// [`settings_ui`] — because a read has no business having a side effect on
/// the file it read from.
///
/// A name this build does not recognise is dropped rather than refused, the
/// same tolerance the sidecar shows an unknown operation: a config written by
/// a newer build must not stop an older one from pasting.
pub fn scope_for(settings: &dr_types::Settings) -> Scope {
let Some(names) = settings.develop.copy_attributes.as_ref() else {
return if settings.develop.copy_includes_framing {
Scope::everything()
} else {
Scope::adjustments()
};
};
Scope::of(names.iter().filter_map(|n| {
let attribute = dr_pipeline::Attribute::from_name(n);
if attribute.is_none() {
log::debug!("preset scope: ignoring unknown attribute {n:?}");
}
attribute
}))
}
/// The names to store for a scope, for the setting [`scope_for`] reads back.
pub fn names_for(scope: Scope) -> Vec<String> {
scope.attributes().map(|a| a.name().to_string()).collect()
}
/// The scope chips, in the order the pipeline declares its attributes.
///
/// Declaration order is roughly the order a photographer works in, which is
/// the order [`Attribute::ALL`] is written for. Nothing here chooses a
/// sequence of its own, and nothing here names an operation (FR-DEV-3c) — the
/// six kinds come from the pipeline and the labels from their descriptors.
pub fn scope_kinds(scope: Scope) -> Vec<ScopeKind> {
dr_pipeline::Attribute::ALL
.into_iter()
.map(|attribute| ScopeKind {
name: attribute.name().into(),
label: label_for(attribute).into(),
on: scope.has(attribute),
})
.collect()
}
/// The word for an attribute.
///
/// Written here rather than taken from `Attribute::label`, which returns a
/// localisation key (`attr.tone`) for a catalogue that does not exist yet.
/// When it does, this becomes a lookup and the strings leave this file — which
/// is why they are gathered in one function rather than spread through the
/// panel.
fn label_for(attribute: dr_pipeline::Attribute) -> &'static str {
use dr_pipeline::Attribute;
match attribute {
Attribute::Tone => "Tone",
Attribute::Colour => "Colour",
Attribute::Detail => "Detail",
Attribute::Optics => "Optics",
Attribute::Geometry => "Geometry",
Attribute::Effect => "Effect",
}
}
/// Push the current scope onto the window, wherever it is drawn.
pub fn render_scope(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
let scope = scope_for(&settings.snapshot());
let kinds: Vec<ScopeKind> = scope_kinds(scope);
window.set_copy_scope_kinds(slint::ModelRc::new(slint::VecModel::from(kinds)));
window.set_copy_scope_empty(scope.is_empty());
}
/// Wire the scope chips, which every surface that draws them shares.
pub fn wire_scope(
window: &AppWindow,
settings: Rc<settings_ui::SettingsController>,
clipboard: Rc<Clipboard>,
) {
let weak = window.as_weak();
window.on_copy_scope_toggled(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(attribute) = dr_pipeline::Attribute::from_name(&name) else {
return;
};
// Read, flip, write the whole set. The stored value is a list of names
// rather than a bitfield precisely so that this is the only place that
// has to know how the two representations line up.
let scope = scope_for(&settings.snapshot());
let flipped: Vec<dr_pipeline::Attribute> = dr_pipeline::Attribute::ALL
.into_iter()
.filter(|a| {
if *a == attribute {
!scope.has(*a)
} else {
scope.has(*a)
}
})
.collect();
settings.edit(|s| s.develop.copy_attributes = Some(names_for(Scope::of(flipped))));
render_scope(&w, &settings);
// The clipboard's summary counts operations *in scope*, so it changes
// whenever this does — and a summary describing a scope the buttons
// are no longer using is worse than no summary.
render(&w, &clipboard, &settings);
});
}
// ---------------------------------------------------------------------------
// Local sidecars
// ---------------------------------------------------------------------------
/// The sidecar path for a local image — the file's own path with the
/// extension replaced.
///
/// The same rule [`crate::library::sidecar_path`] applies to remote paths, so
/// a folder synced between the two is read identically from either side.
pub fn local_sidecar_path(image: &Path) -> PathBuf {
image.with_extension(dr_pipeline::sidecar::EXTENSION)
}
/// Read a local sidecar, if there is one.
///
/// Absence is the common case and is not an error. An *unreadable* file yields
/// `None` too, and [`save_local`] independently refuses to overwrite one — so
/// a sidecar this build cannot parse costs the edit being shown, never the
/// edit being kept.
pub fn load_local(image: &Path) -> Option<Sidecar> {
let path = local_sidecar_path(image);
let text = std::fs::read_to_string(&path).ok()?;
match Sidecar::parse(&text) {
Ok(s) => Some(s),
Err(e) => {
log::warn!("sidecar at {} is unreadable ({e})", path.display());
None
}
}
}
/// Read-modify-write a local sidecar.
///
/// Read first for the same reason the remote writer does: the file may already
/// hold a rating, or an operation this build does not know about, and writing
/// a fresh document containing only the current edit would delete both.
/// `masks` carries the local adjustments when this is the image's own edit,
/// and is `None` for a paste, which must leave the target's masks alone — a
/// mask is drawn against one photograph and describes nothing on another. See
/// `library::Amendment::Settings` for the same distinction on the remote path.
pub fn save_local(
image: &Path,
preset: &Preset,
scope: Scope,
masks: Option<&dr_pipeline::mask::MaskStack>,
) -> Result<(), String> {
let path = local_sidecar_path(image);
// Distinguish "no sidecar yet" from "a sidecar this build cannot read".
// Only the second is a refusal — overwriting it would destroy an edit we
// merely failed to understand.
let existing = std::fs::read_to_string(&path).ok();
let mut sidecar = match existing.as_deref() {
None => Sidecar::new(),
Some(text) => Sidecar::parse(text).map_err(|e| {
format!(
"existing sidecar at {} is unreadable ({e}); not overwriting",
path.display()
)
})?,
};
// A local file has no catalog behind it to supply a version identity, so
// the file's own default version is used and one is created if absent.
// Deterministic rather than random: reopening the same photograph must
// amend the version it wrote last time, not accumulate one per save.
let uuid = sidecar
.default_version()
.map(|v| v.uuid.clone())
.unwrap_or_else(|| "local".to_string());
let mut version =
sidecar
.versions
.get(&uuid)
.cloned()
.unwrap_or_else(|| dr_pipeline::sidecar::Version {
uuid: uuid.clone(),
name: "Default".to_string(),
is_default: true,
revision: 0,
..Default::default()
});
preset.amend(&mut version.params, scope);
// Wholesale, not merged: this is the stack as it stands, so a layer the
// user deleted must leave the file too.
if let Some(masks) = masks {
version.masks = masks.clone();
}
version.revision = version.revision.saturating_add(1);
version.modified = now_secs();
sidecar.put(version);
let text = sidecar.to_text();
// Write and rename, so an interrupted save cannot truncate an edit that
// was already safely on disk.
let tmp = path.with_extension("drsc.tmp");
std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Saving and loading the open image
// ---------------------------------------------------------------------------
/// Persist the develop view's current edit to wherever it belongs.
///
/// Called when the image is about to stop being the open one — on leaving for
/// the library, on stepping to the next frame, and on closing the window —
/// rather than on every slider move. A save is a network round-trip on the
/// library path, and one per drag frame would put an upload inside the gesture
/// that NFR-P5 is about.
///
/// Silent on success and logged on failure, deliberately. There is no
/// acknowledgement worth interrupting a photographer for, and the failure that
/// matters — a sidecar this build could not parse — is refused by the writer
/// rather than resolved here.
pub fn save_open_edit(
window: &AppWindow,
stored: &Stored,
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
// Both taken in one borrow: they are one edit, and a mask stack captured
// from a session that had already moved on would be a different image's.
let Some((preset, masks, film)) = session.borrow().as_ref().map(|s| {
(
s.copy_settings(),
s.masks().clone(),
// TRACES: FR-DEV-3f
// Taken in the same borrow as the other two, for the reason the
// note above gives: they are one edit, and a stock read from a
// session that had already moved on would be a different
// photograph's.
s.film()
.map(|(stock, print)| dr_pipeline::sidecar::FilmRef {
stock: stock.to_string(),
print: print.then(|| {
dr_film::find(stock)
.and_then(dr_film::default_print)
.map(|p| p.stock.clone())
.unwrap_or_default()
}),
}),
)
}) else {
return;
};
match stored {
// Nothing to save to. An image that failed to decode, or one opened
// before a library was chosen.
Stored::Nowhere => {}
Stored::Local(path) => {
// `Everything`: this is the image's *own* edit being written back,
// not a paste onto someone else's. Excluding framing here would
// make a crop the one adjustment that never survived a restart.
if let Err(e) = save_local(path, &preset, Scope::everything(), Some(&masks)) {
log::warn!("saving {}: {e}", path.display());
}
}
Stored::Remote { path, version_uuid } => {
let write = library::SidecarWrite {
image_path: path.clone(),
version_uuid: version_uuid.clone(),
amendment: library::Amendment::Settings {
preset,
scope: Scope::everything(),
// The image's own edit, so the whole of it: `Everything`
// already says framing travels, and the local adjustments
// have to travel for the same reason. Without this the
// sliders were written and every mask was dropped.
masks: Some(masks),
film: Some(film),
},
};
library_ui::start_sidecar_writes(window, library, vec![write]);
// TRACES: FR-CAT-9
// And bring the grid's cell up to date, so the photograph the user
// just finished is the photograph they see in the library.
//
// After the sidecar write is queued, never instead of it: the edit
// is the thing that must not be lost, and a render that failed
// must not take the save down with it.
refresh_thumbnail_for(window, path, session, library);
}
}
}
/// TRACES: FR-CAT-9
/// Re-render the grid's thumbnail from the edit that is being saved.
///
/// # When it is worth doing
///
/// Two cases, and the second is the one that is easy to miss.
///
/// An edit made in this sitting is the obvious one — `can-undo` is the app's
/// own record that something was changed, and it stays true for a change that
/// happened to end at neutral, which still needs the thumbnail putting back.
///
/// The other is an image opened with an edit already in its sidecar and left
/// untouched. Nothing changed, so there is nothing to *re-*render — but the
/// cached thumbnail came from the file's embedded preview and has never shown
/// that edit at all. `is_neutral` is what distinguishes it: a graph that does
/// something, against a thumbnail that shows none of it.
///
/// A neutral image nobody touched fails both and costs nothing, which is the
/// common case on a scroll through a library.
fn refresh_thumbnail_for(
window: &AppWindow,
path: &str,
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
let worth_it =
window.get_can_undo() || session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
if !worth_it {
return;
}
// The borrow is held across the renders, which is safe here and would not
// be if this were reachable from a Slint callback that also touches the
// session — it is not: the only caller is on the way out of the view.
let mut borrow = session.borrow_mut();
let Some(open) = borrow.as_mut() else {
return;
};
library_ui::refresh_thumbnail(window, library, path, |edge| open.render_thumbnail(edge));
}
/// Load a stored edit into the open session and refresh the panel.
///
/// Returns whether anything was applied, so the caller can skip a redraw for
/// the common case of a photograph that has never been edited.
pub fn apply_stored_edit(
window: &AppWindow,
sidecar: &Sidecar,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
) -> bool {
let Some(version) = sidecar.default_version() else {
return false;
};
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return false };
s.apply_version(version);
}
crate::sync_rows(window, rows, session);
true
}
// ---------------------------------------------------------------------------
// Wiring
// ---------------------------------------------------------------------------
/// Push the clipboard's state onto the window.
///
/// One function rather than three `set_` calls at each site, because the three
/// properties have to agree: a summary describing a scope the button is not
/// using would be worse than no summary.
pub fn render(
window: &AppWindow,
clipboard: &Rc<Clipboard>,
settings: &Rc<settings_ui::SettingsController>,
) {
let scope = scope_for(&settings.snapshot());
window.set_settings_armed(clipboard.is_armed());
window.set_settings_summary(clipboard.describe(scope).into());
// Only worth saying when it is actually true of *this* copy: a clipboard
// holding no crop loses nothing to the setting, and saying so anyway would
// train the user to ignore the line.
window.set_settings_framing_withheld(
clipboard.holds_framing() && !scope.has(dr_pipeline::Attribute::Geometry),
);
}
/// The develop view's shared state, as this module needs it.
///
/// Bundled rather than passed one by one because these four always travel
/// together — a paste changes the graph, so it must rebuild the panel, redraw
/// the canvas and know where to save, and a call site holding three of the
/// four is a call site with a bug in it.
#[derive(Clone)]
pub struct Develop {
pub session: Rc<RefCell<Option<DevelopSession>>>,
pub rows: Rc<slint::VecModel<ParamRow>>,
pub redraw: Rc<dyn Fn(&AppWindow)>,
/// Where the open image's edit is stored.
pub open: OpenImage,
}
/// Wire copy, paste and batch-paste.
pub fn wire(
window: &AppWindow,
clipboard: Rc<Clipboard>,
develop: Develop,
settings: Rc<settings_ui::SettingsController>,
library: Rc<library_ui::LibraryController>,
collections: Rc<crate::collections_ui::CollectionsController>,
) {
let Develop {
session,
rows,
redraw,
open,
} = develop;
// --- copy ------------------------------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let session = session.clone();
let settings = settings.clone();
window.on_copy_settings(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow().as_ref() {
clipboard.copy_from(s);
}
render(&w, &clipboard, &settings);
});
}
// --- paste onto the open image ---------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let session = session.clone();
let settings = settings.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let open = open.clone();
let library = library.clone();
window.on_paste_settings(move || {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = clipboard.peek() else {
return;
};
let scope = scope_for(&settings.snapshot());
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
s.apply_settings(&preset, scope);
}
// The sliders are showing the values that just moved, so the panel
// has to be rebuilt — a paste is the one edit that changes many
// controls without any of them having been touched.
crate::sync_rows(&w, &rows, &session);
redraw(&w);
// Saved immediately rather than on the way out. A paste is a
// deliberate, discrete action, unlike a drag, and the cost of one
// write is nothing beside the surprise of it not having stuck.
save_open_edit(&w, &open.borrow(), &session, &library);
});
}
// --- paste onto the selection ----------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let settings = settings.clone();
let library = library.clone();
let collections = collections.clone();
window.on_paste_settings_to_selection(move || {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = clipboard.peek() else {
return;
};
let scope = scope_for(&settings.snapshot());
library_ui::paste_settings_to_selection(
&w,
&library,
&collections.selected(),
&preset,
scope,
);
});
}
}
// ---------------------------------------------------------------------------
// Named presets (FR-DEV-6)
// ---------------------------------------------------------------------------
/// TRACES: FR-DEV-6
/// The saved preset library, and the file it lives in.
///
/// # Why the library is held in memory as well as on disk
///
/// Every change writes the whole file (see [`PresetStore`]), so the in-memory
/// copy is what the sheet is drawn from and what the next edit is applied to.
/// Reading the file back after each change would be the same bytes and one
/// more chance for a failed read to empty a list the user is looking at.
///
/// # A failed save is reported, not swallowed
///
/// The clipboard cannot fail — it is memory. This can: a full disk, a config
/// directory that is not writable. Losing a preset the user just named, with
/// the sheet cheerfully listing it, would be discovered at the worst possible
/// moment, so the write's result reaches the window.
pub struct NamedPresets {
store: PresetStore,
library: RefCell<PresetLibrary>,
}
impl NamedPresets {
/// Load the library from its usual place.
pub fn open() -> Rc<Self> {
Self::at(PresetStore::open())
}
/// Load from an explicit store — for tests, and for a non-default location.
#[cfg(test)]
pub fn open_at(path: PathBuf) -> Rc<Self> {
Self::at(PresetStore::open_at(path))
}
fn at(store: PresetStore) -> Rc<Self> {
// TRACES: FR-DEV-6
// Seed the starter set on a device that has never had a library, and
// only then. See `preset_starter` for why this is not a merge:
// re-adding on every start would resurrect one deleted on purpose.
//
// "Never had one" is the *file* not existing rather than the library
// being empty — a photographer who deleted all six must not be handed
// them again on the next launch.
let seeding = !store.path().exists();
let library = if seeding {
let starters = dr_pipeline::starter::library();
if let Err(e) = store.save(&starters) {
// Not fatal. The presets are in memory and usable this
// session; what is lost is their persistence, and refusing to
// start over it would be absurd.
log::warn!("could not write the starter presets: {e}");
}
starters
} else {
store.load()
};
Rc::new(Self {
store,
library: RefCell::new(library),
})
}
/// Read presets from a file or a folder and store them.
///
/// Returns what to tell the user. Names already in the library are
/// replaced, the same rule a save follows — importing the same folder
/// twice leaves one copy of each rather than `Warm Portrait 2`.
fn import(&self, path: &Path) -> String {
let report = dr_preset_xmp::read_path(path);
if report.presets.is_empty() {
return if report.failed > 0 {
format!("Found {} file(s), but none could be read.", report.failed)
} else {
"No .xmp presets there.".to_string()
};
}
let added = report.presets.len();
{
let mut library = self.library.borrow_mut();
for (name, preset) in report.presets {
if let Err(e) = library.insert(&name, preset) {
log::warn!("imported preset {name:?} is unusable ({e:?}); skipping");
}
}
}
if let Err(e) = self.persist(|_| {}) {
return e.message();
}
let mut message = format!(
"Imported {added} preset{}.",
if added == 1 { "" } else { "s" }
);
if report.failed > 0 {
message.push_str(&format!(" {} could not be read.", report.failed));
}
// Said once, however many files carried it: after forty presets the
// useful sentence is that the white balance did not come across.
if !report.unsupported.is_empty() {
message.push_str(" White balance and tone curves do not carry across.");
}
message
}
/// The stored names, in the order they are written.
pub fn names(&self) -> Vec<String> {
self.library.borrow().names().map(String::from).collect()
}
/// The preset stored under `name`.
pub fn get(&self, name: &str) -> Option<Preset> {
self.library.borrow().get(name).cloned()
}
/// Whether a preset is stored under `name`.
#[cfg(test)]
pub fn contains(&self, name: &str) -> bool {
self.library.borrow().contains(name)
}
/// Store `preset` under `name` and persist.
///
/// The in-memory library is updated first and rolled back if the write
/// fails, so what the sheet lists is always what is on disk. The
/// alternative — writing first — would mean holding a preset the file does
/// not have on every failure path.
fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> {
let previous = {
let mut library = self.library.borrow_mut();
let existing = library.get(name.trim()).cloned();
library.insert(name, preset).map_err(SaveError::Name)?;
existing
};
self.persist(|library| match previous {
Some(p) => {
let _ = library.insert(name, p);
}
None => {
library.remove(name.trim());
}
})
}
/// Save the library, undoing the in-memory change if the write fails.
///
/// The rollback is the caller's because only the caller knows what the
/// previous state was. An import passes an empty one deliberately: it may
/// have replaced any number of names, restoring them all would mean
/// snapshotting the whole library, and the honest failure there is to say
/// the write failed and leave what is on screen matching what is in
/// memory until the next successful save.
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
let result = self.store.save(&self.library.borrow());
match result {
Ok(()) => Ok(()),
Err(e) => {
rollback(&mut self.library.borrow_mut());
// Named, the way the settings page names its file: "could not
// save" without saying where leaves the user nothing to check
// and nothing to fix.
Err(SaveError::Write(format!(
"{}: {e}",
self.store.path().display()
)))
}
}
}
}
/// Why a preset could not be saved.
#[derive(Debug)]
enum SaveError {
/// The name itself was refused.
Name(NameError),
/// The library could not be written.
Write(String),
}
impl SaveError {
/// What to put in front of the user.
///
/// The prose lives here rather than in `dr-pipeline`, which depends on
/// nothing and has no business holding user-facing strings.
fn message(&self) -> String {
match self {
Self::Name(NameError::Empty) => "Give the preset a name.".to_string(),
Self::Name(NameError::Unrepresentable) => {
"A preset name cannot contain brackets or line breaks.".to_string()
}
Self::Write(e) => format!("Could not save presets: {e}"),
}
}
}
/// Push the stored names onto the window.
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
let names: Vec<slint::SharedString> = named.names().into_iter().map(Into::into).collect();
window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names)));
}
/// Wire saving, applying, renaming and deleting named presets.
///
/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the
/// same reason: applying a preset to the open image changes the graph, so it
/// has to rebuild the panel, redraw the canvas and know where to save.
pub fn wire_named(
window: &AppWindow,
named: Rc<NamedPresets>,
develop: Develop,
settings: Rc<settings_ui::SettingsController>,
library: Rc<library_ui::LibraryController>,
collections: Rc<crate::collections_ui::CollectionsController>,
) {
let Develop {
session,
rows,
redraw,
open,
} = develop;
render_named(window, &named);
// --- save the open edit under a name ---------------------------------
{
let weak = window.as_weak();
let named = named.clone();
let session = session.clone();
window.on_save_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
w.set_preset_name_error("Open a photograph first.".into());
return;
};
// Captured at full scope, exactly as a copy is: the scope is a
// decision about applying, and a preset that had already discarded
// the crop could never grow it back (see `Preset::capture`).
match named.insert(&name, preset) {
Ok(()) => {
w.set_preset_name_error(Default::default());
render_named(&w, &named);
}
Err(e) => w.set_preset_name_error(e.message().into()),
}
});
}
// A refusal the user has started correcting is stale, and a message that
// outlives its cause is one the user learns to ignore.
{
let weak = window.as_weak();
window.on_preset_name_edited(move |_| {
if let Some(w) = weak.upgrade() {
w.set_preset_name_error(Default::default());
}
});
}
// --- what saving would capture ----------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
window.on_presets_opened(move || {
let Some(w) = weak.upgrade() else { return };
// At the scope a save would use, which is full: a preset keeps
// the framing it was captured with and drops it at apply time.
let summary = session
.borrow()
.as_ref()
.map(|s| describe(&s.copy_settings(), Scope::everything()))
.unwrap_or_default();
w.set_preset_capture_summary(summary.into());
});
}
// --- apply ------------------------------------------------------------
//
// One callback for both targets. Which one is meant is not a guess: the
// sheet was opened from a view that set `preset-apply-count`, and the
// label the user just read said "Applies to 12 selected photographs" or
// said nothing. Deciding here from the same number keeps the promise.
{
let weak = window.as_weak();
let named = named.clone();
let settings = settings.clone();
let library = library.clone();
let collections = collections.clone();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let open = open.clone();
window.on_apply_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = named.get(&name) else {
// Another window may have deleted it since this list was drawn.
render_named(&w, &named);
return;
};
let scope = scope_for(&settings.snapshot());
if w.get_preset_apply_count() > 0 {
library_ui::paste_settings_to_selection(
&w,
&library,
&collections.selected(),
&preset,
scope,
);
} else {
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
s.apply_settings(&preset, scope);
}
// The same three steps a paste takes, for the same reasons:
// many controls moved without any of them being touched, and
// a deliberate discrete action is saved immediately.
crate::sync_rows(&w, &rows, &session);
redraw(&w);
save_open_edit(&w, &open.borrow(), &session, &library);
}
w.set_presets_open(false);
});
}
// --- import from Lightroom --------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_import_presets(move |path| {
let Some(w) = weak.upgrade() else { return };
let path = path.trim();
if path.is_empty() {
return;
}
// Synchronous, deliberately. A preset folder is a few hundred
// small text files and the read is milliseconds; a worker and a
// progress bar would be machinery around a wait nobody sees.
let report = named.import(Path::new(path));
w.set_preset_import_report(report.into());
render_named(&w, &named);
});
}
// --- rename -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_rename_preset(move |from, to| {
let Some(w) = weak.upgrade() else { return };
let renamed = {
let mut library = named.library.borrow_mut();
library.rename(&from, &to)
};
match renamed {
Ok(_) => {
// Nothing to roll back to on a failed write beyond the
// name it had, which is what this restores.
let from = from.to_string();
let to = to.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.rename(&to, &from);
}) {
w.set_preset_name_error(e.message().into());
}
}
Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()),
}
render_named(&w, &named);
});
}
// --- delete -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_delete_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let removed = {
let mut library = named.library.borrow_mut();
let previous = library.get(&name).cloned();
library.remove(&name);
previous
};
if let Some(previous) = removed {
let name = name.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.insert(&name, previous);
}) {
w.set_preset_name_error(e.message().into());
}
}
render_named(&w, &named);
});
}
}
/// Seconds since the epoch, or zero if the clock is before it.
///
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
/// which resolves on `revision` first anyway (FR-NC-9), and refusing to save
/// because a clock is unset would be far worse.
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::EditGraph;
fn tempdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-presets-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 edited() -> EditGraph {
use dr_pipeline::ops::exposure;
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
g
}
/// The bug this exists to prevent: a local adjustment that survived until
/// the session ended and then did not exist.
///
/// The sidecar format has stored masks since they were added, and
/// `Version::apply` restores them — but the save path wrote a `Preset`,
/// which is a parameter map, and a mask is not a parameter. So the sliders
/// came back and every mask was silently gone. Nothing reported it,
/// because nothing had failed.
#[test]
fn a_mask_survives_being_written_and_read_back() {
use dr_pipeline::mask::{MaskLayer, MaskSource};
let dir = tempdir("mask-roundtrip");
let image = dir.join("a.CR2");
let mut graph = edited();
let mut layer = MaskLayer::new(
"m1",
MaskSource::Radial {
centre: (0.4, 0.6),
radii: (0.2, 0.3),
angle: 0.0,
feather: 0.5,
},
);
layer.opacity = 0.75;
graph.masks_mut().push(layer);
assert_eq!(graph.masks().len(), 1, "the premise: the edit has a mask");
save_local(
&image,
&Preset::capture(&graph),
Scope::everything(),
Some(graph.masks()),
)
.unwrap();
// Read it back the way opening the photograph again does.
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
let sidecar = Sidecar::parse(&text).unwrap();
let version = sidecar.default_version().expect("a default version");
let mut reopened = EditGraph::default_chain();
version.apply(&mut reopened).expect_no_film();
assert_eq!(
reopened.masks().len(),
1,
"the mask must come back with the photograph"
);
let back = &reopened.masks().layers()[0];
assert_eq!(back.id, "m1");
assert!((back.opacity - 0.75).abs() < 1e-6, "and with its settings");
assert!(
matches!(back.source, MaskSource::Radial { .. }),
"and its kind: {:?}",
back.source.kind()
);
}
#[test]
fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() {
// Replaced, not appended, so a RAW and the JPEG beside it share one
// sidecar — they are the same photograph (FR-CAT-11).
assert_eq!(
local_sidecar_path(Path::new("/photos/a.CR2")),
PathBuf::from("/photos/a.drsc")
);
}
#[test]
fn an_edit_survives_a_save_and_a_load() {
use dr_pipeline::ops::exposure;
let dir = tempdir("round-trip");
let image = dir.join("a.CR2");
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let sidecar = load_local(&image).expect("a sidecar was written");
let mut restored = EditGraph::default_chain();
sidecar
.default_version()
.expect("a version")
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
}
#[test]
fn an_image_with_no_sidecar_loads_as_nothing() {
let dir = tempdir("absent");
assert!(load_local(&dir.join("never-edited.CR2")).is_none());
}
#[test]
fn saving_twice_amends_one_version_rather_than_accumulating_them() {
// A random uuid per save would leave the file growing a version every
// time the user left the develop view, and `default_version` would
// start answering with whichever sorted first.
let dir = tempdir("one-version");
let image = dir.join("a.CR2");
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
}
#[test]
fn a_save_bumps_the_revision() {
// FR-NC-9 resolves conflicts by revision; a write that did not bump it
// would lose to a stale remote copy at the next sync.
let dir = tempdir("revision");
let image = dir.join("a.CR2");
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let first = load_local(&image)
.unwrap()
.default_version()
.unwrap()
.revision;
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let second = load_local(&image)
.unwrap()
.default_version()
.unwrap()
.revision;
assert!(second > first, "{second} did not follow {first}");
}
#[test]
fn a_save_does_not_destroy_a_rating_it_never_read() {
// The read-modify-write property. A cull is stored in the same version
// as the edit, and leaving the develop view must not wipe it.
let dir = tempdir("keeps-rating");
let image = dir.join("a.CR2");
let mut sidecar = Sidecar::new();
sidecar.put(dr_pipeline::sidecar::Version {
uuid: "local".to_string(),
name: "Default".to_string(),
is_default: true,
rating: 4,
flag: 1,
..Default::default()
});
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let back = load_local(&image).unwrap();
let v = back.default_version().unwrap();
assert_eq!(v.rating, 4, "the cull was destroyed by an edit");
assert_eq!(v.flag, 1);
}
#[test]
fn a_save_refuses_to_overwrite_an_unreadable_sidecar() {
// The file may hold an edit written by a newer build. Losing it
// because this one could not parse it is the worst available failure
// for an authoritative store.
let dir = tempdir("refuse");
let image = dir.join("a.CR2");
std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap();
assert!(save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None
)
.is_err());
assert_eq!(
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
"drsc 99\n",
"the file was modified despite the refusal"
);
}
#[test]
fn saving_leaves_no_temporary_file_behind() {
let dir = tempdir("no-temp");
save_local(
&dir.join("a.CR2"),
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.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");
}
// --- the clipboard ------------------------------------------------------
#[test]
fn a_fresh_clipboard_is_not_armed() {
assert!(!Clipboard::default().is_armed());
}
#[test]
fn the_scope_follows_the_setting_and_defaults_to_sparing_the_crop() {
let mut settings = dr_types::Settings::default();
assert_eq!(
scope_for(&settings),
Scope::adjustments(),
"a fresh install must not carry crops between photographs"
);
settings.develop.copy_includes_framing = true;
assert_eq!(scope_for(&settings), Scope::everything());
}
#[test]
fn the_description_counts_operations_and_not_parameters() {
use dr_pipeline::ops::{exposure, white_balance};
let clipboard = Clipboard::default();
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 20.0);
g.set_param(white_balance::ID, white_balance::TINT, 5.0);
clipboard.put(Preset::capture(&g));
// Three parameters, two operations.
assert_eq!(clipboard.describe(Scope::adjustments()), "2 adjustments");
}
#[test]
fn a_neutral_copy_is_armed_and_says_so() {
// Copying an unedited frame and pasting it clears the target, which is
// a real action — so the button must be live rather than looking like
// nothing was copied.
let clipboard = Clipboard::default();
clipboard.put(Preset::capture(&EditGraph::default_chain()));
assert!(clipboard.is_armed());
assert_eq!(clipboard.describe(Scope::adjustments()), "Neutral");
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
/// A library that has *already been started*, so the starter presets are
/// not in the way.
///
/// Writing an empty library first is what makes the file exist, which is
/// the condition seeding tests — see `NamedPresets::at`. The seeding
/// itself has its own tests below rather than being switched off with a
/// flag that only tests would set.
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
let dir = tempdir(name);
let path = dir.join("presets.drpl");
PresetStore::open_at(path.clone())
.save(&PresetLibrary::default())
.unwrap();
(NamedPresets::open_at(path), dir)
}
/// A device that has never had a preset library.
fn fresh(name: &str) -> (Rc<NamedPresets>, PathBuf) {
let dir = tempdir(name);
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
}
#[test]
fn a_first_run_is_handed_the_starter_presets() {
// A sheet that opens on "No presets yet" teaches the photographer that
// the feature is homework.
let (presets, _dir) = fresh("first-run");
assert_eq!(presets.names().len(), dr_pipeline::starter::library().len());
assert!(!presets.names().is_empty());
}
#[test]
fn the_starters_are_written_out_so_they_survive_a_restart() {
let (_presets, dir) = fresh("starters-persist");
let path = dir.join("presets.drpl");
assert!(path.exists(), "the starter library was never written");
assert!(!PresetStore::open_at(path).load().is_empty());
}
#[test]
fn a_starter_the_photographer_deleted_stays_deleted() {
// The reason seeding keys on the file existing rather than on the
// library being empty: deleting all six must not hand them back on the
// next launch.
let dir = tempdir("deleted-starters");
let path = dir.join("presets.drpl");
{
let presets = NamedPresets::open_at(path.clone());
for name in presets.names() {
let mut library = presets.library.borrow_mut();
library.remove(&name);
}
presets.persist(|_| {}).unwrap();
}
let again = NamedPresets::open_at(path);
assert!(
again.names().is_empty(),
"the starters came back: {:?}",
again.names()
);
}
#[test]
fn importing_a_folder_stores_what_it_finds() {
let (presets, dir) = named("import-folder");
let from = dir.join("from-lightroom");
std::fs::create_dir_all(&from).unwrap();
std::fs::write(
from.join("warm.xmp"),
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
<crs:Exposure2012>+0.5</crs:Exposure2012>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#,
)
.unwrap();
let report = presets.import(&from);
assert!(report.starts_with("Imported 1 preset."), "{report}");
assert_eq!(presets.names(), vec!["warm".to_string()]);
}
#[test]
fn importing_the_same_folder_twice_leaves_one_copy() {
let (presets, dir) = named("import-twice");
let from = dir.join("from-lightroom");
std::fs::create_dir_all(&from).unwrap();
std::fs::write(
from.join("warm.xmp"),
r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="+0.5"/>"#,
)
.unwrap();
presets.import(&from);
presets.import(&from);
assert_eq!(presets.names(), vec!["warm".to_string()]);
}
#[test]
fn importing_from_nowhere_says_so_rather_than_claiming_success() {
let (presets, _dir) = named("import-nothing");
let report = presets.import(Path::new("/definitely/not/here"));
assert!(report.contains("No .xmp presets"), "{report}");
}
#[test]
fn a_saved_preset_is_on_disk_before_the_call_returns() {
// Not on the way out, and not on a timer: a preset the user named and
// then lost to a crash is the one failure this feature cannot have.
let (presets, dir) = named("saved-immediately");
presets
.insert("Warm", Preset::capture(&edited()))
.expect("saved");
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
}
#[test]
fn a_saved_preset_carries_the_edit_it_captured() {
let (presets, _dir) = named("carries-the-edit");
presets.insert("Warm", Preset::capture(&edited())).ok();
let preset = presets.get("Warm").expect("stored");
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::adjustments());
assert_eq!(
target.param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE
),
Some(1.5)
);
}
#[test]
fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() {
let (presets, _dir) = named("refused-name");
assert!(presets.insert("", Preset::default()).is_err());
assert!(presets.insert("bracket]", Preset::default()).is_err());
assert!(presets.names().is_empty());
}
#[test]
fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() {
// The rollback. A sheet listing a preset the file does not have is a
// loss the user discovers later, at the moment they reach for it.
let dir = tempdir("failed-write");
// A *file* where the store wants a directory, so `create_dir_all`
// fails and the save cannot succeed.
let blocked = dir.join("blocked");
std::fs::write(&blocked, b"not a directory").unwrap();
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
assert!(presets.insert("Warm", Preset::default()).is_err());
// Not "the library is empty": the starter set is held in memory even
// on a device whose config directory cannot be written, which is the
// right behaviour — the presets are usable this session and only their
// persistence is lost. What must not survive is the preset whose write
// failed.
assert!(
!presets.contains("Warm"),
"the failed save left a preset behind: {:?}",
presets.names()
);
}
#[test]
fn a_failed_overwrite_puts_the_original_back() {
// The other half of the rollback, and the one that loses work if it is
// wrong: overwriting is destructive, so a failed overwrite has to
// restore what was there rather than leave the name holding the new
// value the file never received.
use std::os::unix::fs::PermissionsExt;
let dir = tempdir("failed-overwrite");
let path = dir.join("presets.drpl");
let presets = NamedPresets::open_at(path.clone());
presets.insert("Warm", Preset::capture(&edited())).ok();
let original = presets.get("Warm").expect("stored");
// The directory exists, so `create_dir_all` still succeeds and it is
// the write of the temporary file that fails — which is the path a
// full disk takes.
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&dir, perms.clone()).unwrap();
let failed = presets.insert("Warm", Preset::default()).is_err();
// Restore the permissions before asserting, so a failure here does not
// leave an undeletable directory behind for the next run.
perms.set_mode(0o700);
std::fs::set_permissions(&dir, perms).unwrap();
assert!(failed, "the write should have failed");
assert_eq!(
presets.get("Warm"),
Some(original),
"the failed overwrite kept the new value"
);
}
}