Files
DarkRoom/core/dr-pipeline/src/sidecar.rs
T
dtourolle 6b1aac477d Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 16:20:15 +02:00

3206 lines
128 KiB
Rust

//! TRACES: FR-DEV-1
//! Sidecar serialisation — the edit graph as durable, mergeable data.
//!
//! # Generic, for the same reason the UI is generic
//!
//! `dr-ui` builds its panel by walking [`EditGraph::capabilities`] and never
//! names an operation (FR-DEV-3c). This module does the same thing to the
//! same list: it reads every parameter an operation *declares*, and writes
//! the ones that differ from their default. No operation implements a
//! serialisation method, and adding one needs no change here — the
//! descriptor it already publishes for the UI is exactly the description the
//! sidecar needs.
//!
//! That symmetry is the point. There is one place an operation says what its
//! parameters are, and both the interface and the persistence layer read it.
//! A third place would be a third thing to forget to update.
//!
//! # Only non-default values are written
//!
//! An operation at neutral contributes nothing to the file, which is what
//! makes the format survive both directions of version skew:
//!
//! - **Reading an old sidecar in a new build.** An operation added since is
//! simply absent, and absence means default, which means neutral. The
//! image renders as it did.
//! - **Reading a new sidecar in an old build.** An unknown operation's lines
//! are preserved verbatim (see [`Version::unknown`]) and written back
//! untouched, so a device running behind cannot silently destroy an edit it
//! does not understand.
//!
//! The alternative — writing every parameter — would make every file grow
//! with the operation count and would still not solve either case.
//!
//! # Why a flat text format rather than serde
//!
//! FR-NC-9 requires conflict merge **at the edit-graph node level**: a crop
//! made on one device and an exposure change made on another must both
//! survive. In this format a node *is* a line, keyed by `op.param`, so the
//! merge is a key-wise comparison over two maps ([`Version::merge`]) rather
//! than a tree diff. A nested document would need the same map built at merge
//! time anyway.
//!
//! It also keeps this crate dependency-free, which is the property that lets
//! the descriptor and codegen logic be tested without a device (ARCH §6.5a).
//!
//! # Shape
//!
//! ```text
//! drsc 1
//!
//! [version 8f04c0e2-1f9a-4a63-b0e9-3d1f5a0c77b1]
//! name = Default
//! default = 1
//! revision = 7
//! device = 3a1c5f80-9d2e-4b11-8c6a-0f7e2d4b9a35
//! modified = 1754697600
//! exposure.exposure = 0.75
//! framing.crop_w = 0.8
//! ```
//!
//! Values are decimal floats; keys are `op_id.param_id`. Both come from the
//! descriptors, so the file is readable by a human debugging an edit that
//! went wrong — which is the case that matters, since sidecars are the
//! authoritative store (ARCH §6.12) and the catalog is the disposable index.
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use crate::coverage::Coverage;
use crate::graph::EditGraph;
use crate::mask::{
Falloff, Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
};
use crate::preset::Preset;
use crate::spot::{Spot, SpotMode, SpotSet};
use crate::state::{EditState, FilmRebake};
/// Format version of the document itself.
///
/// Bumped only for a change no reader could otherwise survive. Adding an
/// operation is *not* such a change — that is what the non-default rule
/// above buys — so this is expected to stay at 1 for a long time.
pub const FORMAT_VERSION: u32 = 1;
/// The file extension for a DarkRoom sidecar.
pub const EXTENSION: &str = "drsc";
/// Highest star rating. Mirrors `dr_catalog::rating::MAX_RATING`; duplicated
/// rather than shared because this crate deliberately depends on nothing.
pub const MAX_RATING: u8 = 5;
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
pub const MAX_FLAG: u8 = 2;
/// TRACES: FR-CAT-8 | FR-NC-8
/// One image's sidecar: a keyed set of versions.
///
/// A set rather than a single graph because an image may carry several
/// virtual copies (FR-CAT-12), and because version identity has to be part of
/// the format for conflict merge to operate per-version (FR-NC-8).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Sidecar {
/// Versions by uuid. Ordered, so writing the same state twice produces
/// byte-identical output — which is what lets a caller skip an upload by
/// comparing content rather than trusting a dirty flag.
pub versions: BTreeMap<String, Version>,
/// Lines from a `[version]` block whose keys this build did not
/// recognise as `op.param`, and any unrecognised top-level lines.
///
/// Preserved so a older build round-trips a newer file without loss.
unknown_blocks: Vec<String>,
/// TRACES: FR-MRG-6
/// Provenance: the sources a composite was merged from, in order, as
/// the library names them. Empty for a photograph the camera took.
///
/// Top-level rather than per version because it is a fact about the
/// file, not about an edit — every version of a panorama is a version
/// of the same twelve frames. Written as one `derived_from = …` line per
/// source before the first version block, where a build that predates
/// the field keeps the lines as unknown and writes them back untouched.
pub derived_from: Vec<String>,
/// TRACES: FR-MRG-6
/// How the composite was made: `panorama cylindrical 49.7mm 12 frames`.
/// Free text for a panel; the parameters that matter to a re-merge are
/// the sources and the projection, and both are legible in it.
pub merge: Option<String>,
}
/// TRACES: FR-DEV-3f
/// The stock and paper a version names, without the tables they bake to.
///
/// Re-exported rather than defined here: a sidecar is one of the things an
/// edit is written to, not where an edit is defined. See [`crate::state`].
pub use crate::state::FilmRef;
/// TRACES: FR-CAT-12 | FR-NC-8
/// One named edit variant.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Version {
pub uuid: String,
pub name: String,
pub is_default: bool,
/// TRACES: FR-DEV-5
/// The version this is a named snapshot of, if it is one.
///
/// A snapshot *is* an edit state, which is exactly what a version stores,
/// so it is stored as one: the parameters, the masks and their parts, the
/// repairs and the film all arrive through the blocks that already carry
/// them, and a merge keys on the uuid as it does for any other version.
/// What sets a snapshot apart is only this pointer — it belongs to
/// another version's history rather than standing beside it as a variant
/// (FR-CAT-12), so a reader listing what a photograph *is* skips it, and
/// a reader listing what one edit *was* finds it here.
///
/// Written as `snapshot-of`. A build that predates the key reads the
/// block as an ordinary named version and keeps it, which is the right
/// failure: nothing is lost, and the newer build finds it as a snapshot
/// again.
pub snapshot_of: Option<String>,
/// Monotonic per-edit counter (FR-NC-8).
///
/// The primary merge discriminator, ahead of [`Self::modified`]: a device
/// with a skewed clock must not be able to overwrite real work simply by
/// claiming a later timestamp.
pub revision: u64,
/// The device that last wrote this version (FR-NC-8).
pub device: String,
/// Unix seconds. Breaks exact `revision` ties only.
pub modified: i64,
/// TRACES: FR-CAT-5 | FR-CULL-4
/// Star rating, 0..=5. Zero means *unrated*, which is a state rather than
/// a low score — it is what "filter to unjudged" selects.
///
/// Stored here, not only in the catalog, because the catalog is a
/// disposable index (ARCH §6.12): a photographer who culls 3,000 frames
/// and then deletes the catalog must not lose that afternoon's work. This
/// and [`Self::flag`] are the two fields that make a cull durable.
///
/// Written as its own top-level key rather than as an `op.param` line
/// because a rating is not an edit — it changes no pixel, and putting it
/// in the parameter map would make it an operation the graph must own.
pub rating: u8,
/// The pick/reject axis, independent of [`Self::rating`].
///
/// `0` unflagged, `1` pick, `2` reject — matching the catalog's encoding,
/// so a value moving between the two stores needs no translation table
/// that could drift.
pub flag: u8,
/// The edit itself: `(op, param) -> value`, non-default values only.
pub params: BTreeMap<(String, String), f32>,
/// TRACES: FR-DEV-3 | FR-NC-9
/// The local adjustments.
///
/// Written as its own `[mask]` blocks rather than folded into
/// [`Self::params`], because a layer is not a scalar: it carries a
/// selection, a geometry, and a chain of its own. Flattening it into
/// dotted keys would encode a list of region ids as something like
/// `m1.region.0 = 12`, which is neither readable nor mergeable — and
/// per-field merge under FR-NC-9 is most of the reason the ids are stored
/// as ids at all.
pub masks: MaskStack,
/// TRACES: FR-DEV-3f
/// The film stock this version develops on, named by id.
///
/// A top-level key rather than an `op.param` line, for the reason
/// [`Self::rating`] is one and [`Self::masks`] are: a stock is not a
/// scalar. It is a choice of material, and the numbers that render it are
/// derived from the choice rather than being the choice.
///
/// The **id, not an index**. Stocks are files that users add
/// (`core/dr-film/profiles`), so an index would mean installing a profile
/// silently changed which film every existing photograph was developed on.
///
/// Only the names travel. Turning them back into tables needs the profile
/// database, which this crate does not link, so [`Self::apply`] leaves the
/// graph's film cleared and the caller re-bakes — see `EditGraph::set_film`.
pub film: Option<FilmRef>,
/// TRACES: FR-DEV-8 | FR-NC-9
/// The repairs (`docs/dev/spot-removal.md`).
///
/// A line per spot, keyed `spot.<id>`, rather than a block per spot as a
/// mask gets: a spot is eight numbers, and sixty-four blocks would bury the
/// rest of the file. A line *per spot* rather than one line for the set,
/// because the line is the unit of merge and of a readable diff — the same
/// reasoning [`write_strokes`] gives for a line per stroke.
pub spots: SpotSet,
/// Keys this build did not recognise, kept verbatim.
///
/// An operation this build lacks would otherwise be deleted the moment an
/// older device saved the file — silent data loss across a version skew,
/// which for an authoritative store is the worst failure available.
pub unknown: BTreeMap<String, String>,
}
impl Version {
/// A new version holding everything `graph`'s edit consists of.
///
/// The destructuring is exhaustive on purpose — see [`crate::state`]. A
/// new part of an edit must not reach the file only by somebody
/// remembering to add a line here, which is how [`Self::update`] came to
/// write the masks and forget the film.
pub fn from_graph(uuid: impl Into<String>, name: impl Into<String>, graph: &EditGraph) -> Self {
let EditState {
params,
masks,
film,
spots,
} = graph.state();
let params = params.into_params();
let masks = (*masks).clone();
Self {
uuid: uuid.into(),
name: name.into(),
is_default: false,
snapshot_of: None,
revision: 1,
device: String::new(),
modified: 0,
// A new version is unjudged: the graph says nothing about whether
// the photograph is any good, and inventing a rating here would
// put every image at zero stars *deliberately* rather than leaving
// it in the "not yet looked at" state a cull resumes from.
rating: 0,
flag: 0,
params,
masks,
film,
spots,
unknown: BTreeMap::new(),
}
}
/// Apply this version's edit to a graph, returning the film it still owes.
///
/// Loading is a *replacement* rather than an overlay: a parameter absent
/// from the file means default, and would otherwise silently inherit
/// whatever the graph happened to hold.
///
/// Unknown operations and parameters are skipped with a warning by
/// [`EditGraph::set_param`], and values are clamped there, so a corrupt
/// or newer file cannot reach a shader.
///
/// The [`FilmRebake`] is not a new obligation — restoring a stock always
/// needed the profile database this crate does not link (ARCH §6.5a), and
/// callers were already doing it from a comment. It is the same debt made
/// impossible to walk past.
pub fn apply(&self, graph: &mut EditGraph) -> FilmRebake {
// Reset first for the *viewport's* sake, and only that: `set_state`
// deliberately preserves the view so an undo does not read as
// navigation, whereas opening a photograph should show it fitted
// rather than at the zoom the previous one was inspected at.
graph.reset();
graph.set_state(&EditState {
params: Preset::from_params(self.params.clone()),
masks: std::sync::Arc::new(self.masks.clone()),
film: self.film.clone(),
spots: self.spots.clone(),
})
}
/// TRACES: FR-DEV-5
/// Whether this version is a named snapshot of another one.
pub fn is_snapshot(&self) -> bool {
self.snapshot_of.is_some()
}
/// Record `graph` into this version, bumping the revision.
///
/// The revision bump is what makes this the write path rather than a
/// setter: FR-NC-9 resolves conflicts by revision, so a local edit that
/// did not bump it is a local edit a remote one will silently win.
pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) {
// Exhaustive, and this is the call site that proves why it has to be:
// this method wrote the parameters, the masks and the repairs and
// silently dropped the film, so saving an edit developed on a stock
// lost the stock. Nothing here can be forgotten now without failing to
// compile.
let EditState {
params,
masks,
film,
spots,
} = graph.state();
self.params = params.into_params();
self.masks = (*masks).clone();
self.film = film;
self.spots = spots;
self.revision = self.revision.saturating_add(1);
self.device = device.to_string();
self.modified = now;
}
/// Merge the mask stacks, returning the layers that genuinely conflicted.
///
/// Reported as `("mask", id)` so a caller surfacing conflicts can show
/// them in the same list as contested parameters without needing a second
/// channel for them.
fn merge_masks(
&mut self,
remote: &Version,
base: Option<&Version>,
remote_wins: bool,
) -> Vec<(String, String)> {
let empty = MaskStack::new();
let base_masks = base.map(|b| &b.masks).unwrap_or(&empty);
let mut conflicts = Vec::new();
let ids: Vec<String> = self
.masks
.layers()
.iter()
.chain(remote.masks.layers())
.map(|l| l.id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
for id in ids {
let ours = self.masks.get(&id);
let theirs = remote.masks.get(&id);
let was = base_masks.get(&id);
let we_changed = ours != was;
let they_changed = theirs != was;
match (we_changed, they_changed) {
// Only they touched it: take theirs, including a deletion.
(false, true) => match theirs {
Some(layer) => self.put_mask(layer.clone()),
None => {
self.masks.remove(&id);
}
},
(true, true) if ours != theirs => {
conflicts.push(("mask".to_string(), id.clone()));
if remote_wins {
match theirs {
Some(layer) => self.put_mask(layer.clone()),
None => {
self.masks.remove(&id);
}
}
}
}
_ => {}
}
}
conflicts
}
/// TRACES: FR-DEV-8 | FR-NC-9
/// Merge the spot sets, returning the repairs that genuinely conflicted.
///
/// By id, exactly as [`Self::merge_masks`] does, and for the same reason
/// one level down: a repair made on the phone and a repair made on the
/// desktop are different ids, so both survive and neither is a conflict.
/// That is most of why [`crate::spot::Spot::derive_id`] hashes the position
/// rather than counting — with counted ids the two would collide here and
/// one would be lost.
///
/// A spot *both* sides moved resolves wholesale to the higher revision.
/// Half of one device's offset with the other's radius is a repair neither
/// photographer made, and unlike a mask there is not even a case for
/// interleaving: eight numbers describe one disc.
fn merge_spots(
&mut self,
remote: &Version,
base: Option<&Version>,
remote_wins: bool,
) -> Vec<(String, String)> {
let empty = SpotSet::new();
let base_spots = base.map(|b| &b.spots).unwrap_or(&empty);
let mut conflicts = Vec::new();
let ids: Vec<String> = self
.spots
.spots()
.iter()
.chain(remote.spots.spots())
.map(|s| s.id.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
for id in ids {
let ours = self.spots.get(&id);
let theirs = remote.spots.get(&id);
let was = base_spots.get(&id);
let we_changed = ours != was;
let they_changed = theirs != was;
match (we_changed, they_changed) {
// Only they touched it: take theirs, a deletion included.
(false, true) => match theirs {
Some(spot) => self.put_spot(spot.clone()),
None => {
self.spots.remove(&id);
}
},
(true, true) if ours != theirs => {
conflicts.push(("spot".to_string(), id.clone()));
if remote_wins {
match theirs {
Some(spot) => self.put_spot(spot.clone()),
None => {
self.spots.remove(&id);
}
}
}
}
_ => {}
}
}
conflicts
}
/// Replace a repair of the same id, or append it.
///
/// Position is not merged, for the reason [`Self::put_mask`] gives and one
/// more: the order only decides which of two *overlapping* repairs lands on
/// top, and repairs that overlap are already a case the photographer will
/// look at.
fn put_spot(&mut self, spot: Spot) {
match self.spots.get_mut(&spot.id) {
Some(existing) => *existing = spot,
None => {
self.spots.place(spot);
}
}
}
/// Replace a layer of the same id, or append it.
///
/// Position is not merged. Two devices that reordered the same stack have
/// no combined order that is either one's, and layer order only decides
/// which of two *overlapping* masks composites last — a much smaller
/// wrong than losing a layer.
fn put_mask(&mut self, layer: MaskLayer) {
match self.masks.get_mut(&layer.id) {
Some(existing) => *existing = layer,
None => {
self.masks.push(layer);
}
}
}
/// TRACES: FR-NC-9
/// Merge a remote version into this one at the node level.
///
/// Disjoint edits both survive: a crop made on one device and an exposure
/// change made on the other are different keys, so neither is a conflict
/// and the result carries both. Only a key *both* sides changed is
/// ambiguous, and those resolve wholesale to the higher revision — not
/// per key, because two values of the same parameter cannot be combined
/// into a third that either user intended.
///
/// Returns the keys that genuinely conflicted, so a caller can surface
/// them (FR-NC-9: "only genuinely ambiguous merges surface to the UI").
pub fn merge(&mut self, remote: &Version, base: Option<&Version>) -> Vec<(String, String)> {
let empty = BTreeMap::new();
let base_params = base.map(|b| &b.params).unwrap_or(&empty);
let changed = |side: &BTreeMap<(String, String), f32>, key: &(String, String)| {
side.get(key) != base_params.get(key)
};
// The remote wins ties by revision, then by timestamp. Computed once:
// applying it per key would let a single merge take some keys from
// each side, producing a state neither device ever had.
let remote_wins = (remote.revision, remote.modified) > (self.revision, self.modified);
let mut conflicts = Vec::new();
let keys: Vec<(String, String)> = self
.params
.keys()
.chain(remote.params.keys())
.chain(base_params.keys())
.cloned()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
for key in keys {
let ours = changed(&self.params, &key);
let theirs = changed(&remote.params, &key);
match (ours, theirs) {
// Only they touched it — take theirs. This is the disjoint
// case, and the whole reason the merge is key-wise.
(false, true) => match remote.params.get(&key) {
Some(v) => {
self.params.insert(key, *v);
}
None => {
self.params.remove(&key);
}
},
// Both touched it, to different values: genuinely ambiguous.
(true, true) if self.params.get(&key) != remote.params.get(&key) => {
conflicts.push(key.clone());
if remote_wins {
match remote.params.get(&key) {
Some(v) => {
self.params.insert(key, *v);
}
None => {
self.params.remove(&key);
}
}
}
}
// Only we touched it, or both landed on the same value.
_ => {}
}
}
// Judgement is not a parameter and is not merged key-wise: a rating is
// a single scalar, so there is no disjoint case to preserve — two
// devices that both rated a frame simply disagree, and the higher
// revision is the answer, exactly as for a contested parameter.
//
// The asymmetry with `params` is deliberate. A device that has *not*
// rated a frame holds 0, which is indistinguishable from "rated
// zero", so treating the remote's 0 as an edit would let an
// un-culled device silently wipe the ratings of a culled one. Taking
// a non-zero remote value when we hold none is the safe direction:
// a judgement can be added across devices but never erased by one
// that never had it.
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
self.flag = merge_judgement(self.flag, remote.flag, remote_wins);
// TRACES: FR-DEV-3f
// The film resolves wholesale to the higher revision, like a mask
// layer and unlike a parameter. It is one decision with two names in
// it: taking the stock from one device and the paper from the other
// would print a negative on a paper nobody chose it for, which is a
// combination neither photographer asked for and which renders as a
// colour cast rather than as an obvious mistake.
//
// Unlike a rating, a cleared film *is* an edit — "develop this
// normally again" — so `None` propagates where a zero rating does not.
// The revision is what says whether it was cleared or never set.
if remote_wins {
self.film = remote.film.clone();
}
// Masks merge by layer id, which is the same disjoint-survives rule
// the parameters follow one level up: a layer added on the phone and
// a layer added on the desktop are different ids, so both survive and
// neither is a conflict.
//
// A layer *both* sides edited resolves wholesale to the higher
// revision rather than field by field. Two people's versions of one
// mask cannot be interleaved into a third — half of one selection
// plus half of another's opacity is a layer neither of them made —
// so the layer is the unit, exactly as the value is for a parameter.
conflicts.extend(self.merge_masks(remote, base, remote_wins));
conflicts.extend(self.merge_spots(remote, base, remote_wins));
// Unknown keys follow the same rule, so an operation neither side
// understands is not dropped by the merge either.
for (k, v) in &remote.unknown {
self.unknown.entry(k.clone()).or_insert_with(|| v.clone());
}
// The merged result is newer than either input, or the next write
// would look stale to a device that has already seen the remote.
self.revision = self.revision.max(remote.revision).saturating_add(1);
self.modified = self.modified.max(remote.modified);
conflicts
}
}
/// Resolve one judgement scalar — a rating or a flag — across two devices.
///
/// Zero carries no information here. A device that has never judged a frame
/// holds 0, and that is indistinguishable from a deliberate "back to
/// unrated", so the two cases cannot be told apart from the value alone. The
/// resolution follows from which mistake is worse:
///
/// - Taking a remote judgement when we have none **adds** work that was
/// genuinely done elsewhere. If it was wrong, the user re-presses a key.
/// - Taking a remote zero when we have a rating **erases** an afternoon of
/// culling, silently, on a device that was never involved.
///
/// So a zero never overwrites a judgement; a real judgement overwrites ours
/// only when the remote also wins on revision. The cost is that clearing a
/// rating does not propagate — pressing `0` on one device leaves the other
/// device's star standing. That is the deliberate trade, and it is the same
/// direction of caution the merge takes everywhere else.
fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 {
match (ours, theirs) {
// Nothing to lose: any real remote judgement is strictly more
// information than we hold.
(0, t) => t,
// We hold one and they hold none — theirs says nothing.
(o, 0) => o,
// Both judged. A genuine disagreement, resolved by revision like any
// other contested value.
(o, t) => {
if remote_wins {
t
} else {
o
}
}
}
}
impl Sidecar {
pub fn new() -> Self {
Self::default()
}
/// TRACES: FR-NC-8 | FR-NC-9
/// The version marked default, or the newest if none is.
///
/// # Why this compares revisions rather than taking the first
///
/// This used to answer with the first `is_default` in map order, and map
/// order is *uuid* order. That is only ever right when exactly one version
/// claims to be the default, and two devices editing one photograph
/// routinely produce two that do — see [`Self::fuse_default_versions`] for
/// how they come to exist. With two, the answer became a per-photograph
/// coin toss decided by which random uuid happened to sort lower, and the
/// losing device's afternoon of work simply did not appear.
///
/// So the same discriminator the merge uses decides it here: `revision`
/// first, `modified` only to break an exact tie. A device with a skewed
/// clock cannot win by claiming a later timestamp (FR-NC-8), and the
/// answer no longer depends on how a uuid sorts.
///
/// This is a *reader's* resolution and deliberately not a merge: it picks
/// a version, it does not combine two. Anything that goes on to write the
/// file must fuse first, or the edit it did not pick is still sitting
/// there to be picked next time.
pub fn default_version(&self) -> Option<&Version> {
self.versions
.values()
.filter(|v| v.is_default)
.max_by_key(|v| (v.revision, v.modified))
// Never a snapshot: a file with no default and a snapshot in it
// is a file whose edit is *missing*, and answering with a saved
// state of it would open the photograph at a moment the
// photographer deliberately stepped away from.
.or_else(|| self.versions.values().find(|v| !v.is_snapshot()))
}
/// TRACES: FR-DEV-5
/// The named snapshots of one version, oldest first.
///
/// Taken time is `modified`, so the order is the order they were taken in
/// whichever device took them; the uuid breaks a tie so the list reads
/// the same on every device.
pub fn snapshots_of(&self, uuid: &str) -> Vec<&Version> {
let mut out: Vec<&Version> = self
.versions
.values()
.filter(|v| v.snapshot_of.as_deref() == Some(uuid))
.collect();
out.sort_by(|a, b| (a.modified, &a.uuid).cmp(&(b.modified, &b.uuid)));
out
}
/// TRACES: FR-DEV-5 | FR-NC-9
/// Write a session's snapshots of `uuid` into the file.
///
/// `removed` are the ones the session deleted, taken out by id;
/// `snapshots` are the ones it holds, put in. A snapshot in the file that
/// is in neither — one another device took since this session opened
/// the photograph — is left standing, which is the same rule
/// [`Version::merge`] keeps for a version only one side has: never treat
/// "I did not see it" as "I removed it".
///
/// Each snapshot is re-pointed at `uuid` on the way in, because the
/// default version may have been fused onto a canonical identity since
/// the snapshot was taken, and a snapshot of a uuid nothing carries is
/// a snapshot of nothing.
pub fn replace_snapshots(&mut self, uuid: &str, snapshots: Vec<Version>, removed: &[String]) {
for id in removed {
self.versions.remove(id);
}
for mut snapshot in snapshots {
snapshot.snapshot_of = Some(uuid.to_string());
snapshot.is_default = false;
self.put(snapshot);
}
}
/// TRACES: FR-NC-8 | FR-NC-9
/// Collapse every version claiming to be the default onto one identity.
///
/// # The failure this repairs
///
/// A version's uuid is the identity a cross-device merge keys on, and for
/// a long time each device minted its own at random for the same
/// photograph. Two devices editing one frame therefore wrote two
/// `[version]` blocks, both marked `default = 1`, and
/// [`Version::merge`] — which is perfectly capable of combining them —
/// was never handed the pair, because it matches on uuid and the uuids
/// never matched. The two edits simply accumulated, and whichever one
/// [`Self::default_version`] happened to pick was the only one anybody
/// saw.
///
/// Minting the uuid deterministically stops new splits. It does nothing
/// for the files already written, which is what this is for.
///
/// # Why folding into the winner rather than merging in sequence
///
/// [`Version::merge`] raises its own `revision` to `max + 1` as it goes,
/// so merging a chain in ascending order stops being ascending after the
/// first step and the third version would lose to the accumulator. Folding
/// *into* the highest `(revision, modified)` avoids the question: every
/// other version is merged in as the remote and loses every contested
/// value, while the disjoint case — a key only it holds — is taken
/// regardless of who wins, which is the whole point of a key-wise merge.
/// Ratings come across under [`merge_judgement`], so a device that never
/// judged the frame cannot erase one that did.
///
/// The result is the same on every device, because it is a function of the
/// file's contents alone. Two devices that fuse independently and upload
/// converge rather than ping-ponging.
///
/// # `canonical`
///
/// The uuid the result should carry — the caller's derived identity for
/// this photograph (`dr_catalog::rating::derived_version_uuid`). Passing
/// `None` folds onto the lexicographically smallest of the defaults, which
/// is still device-independent and is the rule
/// `dr_catalog::keywords::fuse_duplicates` already uses for the same shape
/// of problem.
///
/// A rename happens even when there is nothing to fuse: a lone default
/// still sitting under a randomly minted uuid has to move to the derived
/// one, or the next write finds no match and adds a *third* version.
///
/// Returns the uuid the default now carries, or `None` if there was no
/// default version to fuse.
pub fn fuse_default_versions(&mut self, canonical: Option<&str>) -> Option<String> {
let mut defaults: Vec<String> = self
.versions
.values()
.filter(|v| v.is_default)
.map(|v| v.uuid.clone())
.collect();
defaults.sort();
let winner_uuid = self
.versions
.values()
.filter(|v| v.is_default)
.max_by_key(|v| (v.revision, v.modified))
.map(|v| v.uuid.clone())?;
// A canonical uuid that already names a *different*, non-default
// version is a virtual copy (FR-CAT-12), and renaming onto it would
// delete it. Vanishingly unlikely — a derived uuid is structurally
// distinct from a randomly minted one — but silently destroying a
// named edit is precisely the loss this module exists to prevent, so
// the rename is declined rather than risked.
let target = match canonical {
Some(c) if !defaults.iter().any(|u| u == c) && self.versions.contains_key(c) => {
log::warn!(
"not renaming the default version onto {c}: another version already has it"
);
winner_uuid.clone()
}
Some(c) => c.to_string(),
None => defaults
.first()
.cloned()
.unwrap_or_else(|| winner_uuid.clone()),
};
let mut winner = self.versions.remove(&winner_uuid)?;
for uuid in defaults.iter().filter(|u| **u != winner_uuid) {
if let Some(loser) = self.versions.remove(uuid) {
winner.merge(&loser, None);
}
}
winner.uuid = target.clone();
self.versions.insert(target.clone(), winner);
Some(target)
}
/// Insert or replace a version.
pub fn put(&mut self, version: Version) {
self.versions.insert(version.uuid.clone(), version);
}
/// Serialise to the on-disk form.
///
/// Deterministic: the same state always produces the same bytes, so a
/// caller may compare content to decide whether an upload is needed.
pub fn to_text(&self) -> String {
let mut out = format!("drsc {FORMAT_VERSION}\n");
// TRACES: FR-MRG-6
for source in &self.derived_from {
let _ = writeln!(out, "derived_from = {source}");
}
if let Some(merge) = &self.merge {
let _ = writeln!(out, "merge = {merge}");
}
for block in &self.unknown_blocks {
let _ = writeln!(out, "{block}");
}
for v in self.versions.values() {
let _ = write!(out, "\n[version {}]\n", v.uuid);
let _ = writeln!(out, "name = {}", v.name);
if v.is_default {
let _ = writeln!(out, "default = 1");
}
// TRACES: FR-DEV-5
if let Some(of) = &v.snapshot_of {
let _ = writeln!(out, "snapshot-of = {of}");
}
let _ = writeln!(out, "revision = {}", v.revision);
if !v.device.is_empty() {
let _ = writeln!(out, "device = {}", v.device);
}
let _ = writeln!(out, "modified = {}", v.modified);
// Judgement, written only when there is one. An unrated,
// unflagged frame contributes nothing — the same non-default rule
// the parameters follow, so a library that has never been culled
// does not grow a line per file.
if v.rating > 0 {
let _ = writeln!(out, "rating = {}", v.rating);
}
if v.flag > 0 {
let _ = writeln!(out, "flag = {}", v.flag);
}
// TRACES: FR-DEV-3f
// Before the parameters, because it decides what they mean: the
// film's exposure slider is a slider on *that stock's* curve.
if let Some(film) = &v.film {
let _ = writeln!(out, "film = {}", film.stock);
if let Some(print) = &film.print {
let _ = writeln!(out, "film_print = {print}");
}
}
for ((op, param), value) in &v.params {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
}
// TRACES: FR-DEV-8
// In the order they were made, which is the order they are drawn
// in and the order that decides which repairs share a pass
// (`SpotSet::rounds`). A `BTreeMap` here — sorting them by id —
// would look tidier in the file and would silently reorder the
// photograph.
for spot in v.spots.spots() {
write_spot(&mut out, spot);
}
for (k, raw) in &v.unknown {
let _ = writeln!(out, "{k} = {raw}");
}
for layer in v.masks.layers() {
write_mask(&mut out, &v.uuid, layer);
}
}
out
}
/// Parse the on-disk form.
///
/// Tolerant by design. A sidecar is the authoritative store, so a single
/// unreadable line must cost that line and not the file: unrecognised
/// keys are preserved rather than rejected, and a malformed value is
/// skipped with a warning. The one hard failure is a format version this
/// build does not understand, where continuing would mean guessing.
pub fn parse(text: &str) -> Result<Self, ParseError> {
let mut lines = text.lines();
let header = lines.next().unwrap_or_default().trim();
let format = header
.strip_prefix("drsc ")
.and_then(|v| v.trim().parse::<u32>().ok())
.ok_or(ParseError::NotASidecar)?;
if format > FORMAT_VERSION {
return Err(ParseError::UnsupportedVersion(format));
}
let mut sidecar = Sidecar::new();
let mut current: Option<Version> = None;
// Mask blocks are collected rather than attached as they are read.
// They name their version explicitly, so they need not follow it in
// the file — and a `[mask]` block for a version that never appears is
// then simply dropped instead of corrupting whichever version
// happened to be open.
let mut mask: Option<PartialMask> = None;
// Whether the `[part]` block being read names a mask that is not the
// one above it. Its keys are dropped rather than falling through to
// the version, where they would be read as somebody's global edit.
let mut orphan_part = false;
let mut masks: Vec<(String, MaskLayer)> = Vec::new();
for raw in lines {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(uuid) = line
.strip_prefix("[version ")
.and_then(|s| s.strip_suffix(']'))
{
masks.extend(mask.take().and_then(PartialMask::finish));
orphan_part = false;
if let Some(v) = current.take() {
sidecar.put(v);
}
current = Some(Version {
uuid: uuid.trim().to_string(),
..Version::default()
});
continue;
}
if let Some(head) = line
.strip_prefix("[mask ")
.and_then(|s| s.strip_suffix(']'))
{
masks.extend(mask.take().and_then(PartialMask::finish));
orphan_part = false;
match head.split_once(char::is_whitespace) {
Some((version, id)) => {
mask = Some(PartialMask::new(version.trim(), id.trim()));
}
// A header missing one of its two names cannot be
// attached to anything. Dropped with a warning rather
// than guessed at, since guessing would put someone
// else's adjustment on this photograph.
None => log::warn!("sidecar: malformed mask header '{line}'; ignoring"),
}
continue;
}
// A part of the mask block above this one. **Three names**, and
// the two it repeats are checked rather than assumed: a block
// reordered by a hand edit or by a merge would otherwise attach
// somebody's correction to whichever mask happened to precede it,
// which is a wrong mask rather than a missing one.
if let Some(head) = line
.strip_prefix("[part ")
.and_then(|s| s.strip_suffix(']'))
{
let mut names = head.split_whitespace();
orphan_part = true;
if let (Some(version), Some(layer), Some(part)) =
(names.next(), names.next(), names.next())
{
match mask.as_mut() {
Some(m) if m.version == version && m.id == layer => {
m.begin_part(part);
orphan_part = false;
}
_ => log::warn!(
"sidecar: part '{part}' names mask {layer} of version {version}, \
which is not the block it follows; ignoring it"
),
}
} else {
log::warn!("sidecar: malformed part header '{line}'; ignoring");
}
continue;
}
let Some((key, value)) = line.split_once('=') else {
// Not a key-value line and not a block header. Keep it so a
// newer format's construct survives a round trip here.
match &mut current {
Some(v) => {
v.unknown.insert(line.to_string(), String::new());
}
None => sidecar.unknown_blocks.push(line.to_string()),
}
continue;
};
let (key, value) = (key.trim(), value.trim());
if orphan_part {
continue;
}
if let Some(m) = mask.as_mut() {
m.set(key, value);
continue;
}
let Some(version) = current.as_mut() else {
// TRACES: FR-MRG-6
match key {
"derived_from" => sidecar.derived_from.push(value.to_string()),
"merge" => sidecar.merge = Some(value.to_string()),
_ => sidecar.unknown_blocks.push(line.to_string()),
}
continue;
};
match key {
"name" => version.name = value.to_string(),
"default" => version.is_default = value != "0",
// TRACES: FR-DEV-5
"snapshot-of" => version.snapshot_of = Some(value.to_string()),
"revision" => version.revision = value.parse().unwrap_or(0),
"device" => version.device = value.to_string(),
"modified" => version.modified = value.parse().unwrap_or(0),
// Clamped rather than trusted: this file may have been written
// by a build with a wider scale, or hand-edited. An
// out-of-range rating would sort above five stars forever and
// no filter would reach it.
"rating" => version.rating = value.parse::<u8>().unwrap_or(0).min(MAX_RATING),
// TRACES: FR-DEV-3f
// Not validated against the installed stocks here: this crate
// does not link them, and a file naming a stock this device
// lacks must round-trip unharmed rather than be silently
// dropped. Whoever bakes it reports the miss.
"film" => {
version.film.get_or_insert_with(FilmRef::default).stock = value.to_string();
}
"film_print" => {
// `get_or_insert` and not a plain field write: key order in
// a hand-edited file is not guaranteed, and a paper line
// above its film line must not be thrown away.
version.film.get_or_insert_with(FilmRef::default).print =
Some(value.to_string());
}
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
// TRACES: FR-DEV-8
// Ahead of the `op.param` arm below, which would otherwise try
// to read eight numbers as one float and drop the repair with a
// warning about a corrupt value. The prefix is safe because a
// spot is deliberately *not* an operation (see `crate::spot`),
// so no `ops/` declaration can ever claim the name.
k if k.starts_with("spot.") => {
let id = &k["spot.".len()..];
match parse_spot(id, value) {
Some(spot) => {
version.spots.place(spot);
}
None => log::warn!("sidecar: unreadable spot {id}; ignoring it"),
}
}
_ => match key.split_once('.') {
// An `op.param` line whose value does not parse is a
// corrupt number, not an unknown key; dropping it lets
// the rest of the edit load, which beats failing the file.
Some((op, param)) => match value.parse::<f32>() {
Ok(v) if v.is_finite() => {
version
.params
.insert((op.to_string(), param.to_string()), v);
}
_ => log::warn!("sidecar: unreadable value for {key}; ignoring"),
},
None => {
version.unknown.insert(key.to_string(), value.to_string());
}
},
}
}
masks.extend(mask.take().and_then(PartialMask::finish));
if let Some(v) = current.take() {
sidecar.put(v);
}
for (uuid, layer) in masks {
match sidecar.versions.get_mut(&uuid) {
Some(v) => {
v.masks.push(layer);
}
None => log::warn!(
"sidecar: mask {} names version {uuid}, which is not in this file; ignoring",
layer.id
),
}
}
Ok(sidecar)
}
}
/// TRACES: FR-DEV-8
/// Write one repair as one line.
///
/// Fields in order: centre, radius, feather, source offset, opacity, mode —
/// and the word `disabled` when it is switched off, which is rare enough that
/// it is a trailing marker rather than a ninth number every line carries.
///
/// Values are written at the precision they are held at (see `crate::spot`'s
/// grid), so a round trip is exact and two devices that placed the same repair
/// produce the same line rather than a diff of noise in the sixth decimal.
fn write_spot(out: &mut String, spot: &Spot) {
let _ = write!(
out,
"spot.{} = {} {} {} {} {} {} {} {}",
spot.id,
format_value(spot.centre.0),
format_value(spot.centre.1),
format_value(spot.radius),
format_value(spot.feather),
format_value(spot.offset.0),
format_value(spot.offset.1),
format_value(spot.opacity),
spot.mode.name(),
);
if !spot.enabled {
let _ = write!(out, " disabled");
}
let _ = writeln!(out);
}
/// TRACES: FR-DEV-8
/// Read one `spot.<id> = …` line, or nothing if it cannot be trusted.
///
/// A malformed line costs that repair and not the file, which is the rule
/// [`parse_stroke`] follows and for the sharper version of its reason: a spot
/// read half-way is a patch of one part of the photograph copied over another
/// part at random. A missing repair is noticed and re-made in a second; a
/// repair in the wrong place looks like the file is damaged.
fn parse_spot(id: &str, value: &str) -> Option<Spot> {
if id.is_empty() {
return None;
}
let mut tokens = value.split_whitespace();
let mut number = || {
tokens
.next()
.and_then(|t| t.parse::<f32>().ok())
.filter(|v| v.is_finite())
};
let centre = (number()?, number()?);
let radius = number()?;
let feather = number()?;
let offset = (number()?, number()?);
let opacity = number()?;
let mode = SpotMode::from_name(tokens.next()?)?;
// Anything after the mode that is not the one marker this format defines
// is a newer build's business. Ignored rather than refused: the repair is
// complete without it, and refusing would drop work over a field this
// build simply does not know about yet.
let enabled = !tokens.any(|t| t == "disabled");
let mut spot = Spot::new(centre, offset, radius);
spot.id = id.to_string();
spot.set_feather(feather);
spot.set_opacity(opacity);
spot.mode = mode;
spot.enabled = enabled;
Some(spot)
}
/// Write one mask layer as its own block.
///
/// The version uuid is repeated in the header rather than relying on the
/// block's position in the file. A sidecar is edited by hand, merged by two
/// devices, and round-tripped by builds that do not know what a mask is —
/// under all three, "belongs to whichever version appeared above me" is a
/// relationship that quietly breaks. Naming it costs one field.
fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
let _ = write!(out, "\n[mask {version} {}]\n", layer.id);
if !layer.name.is_empty() {
let _ = writeln!(out, "name = {}", layer.name);
}
// **The base part is written into the layer's own block**, without a name
// and without a join, and that is what makes this format change no file
// that does not use it: a layer of one part is the same bytes it always
// was, and a block with no `[part]` after it reads back as one part
// (see [`PartialMask::finish`]).
write_source(out, layer.base());
if layer.invert {
let _ = writeln!(out, "invert = 1");
}
if layer.opacity != 1.0 {
let _ = writeln!(out, "opacity = {}", format_value(layer.opacity));
}
if !layer.enabled {
let _ = writeln!(out, "enabled = 0");
}
// TRACES: FR-DEV-19a
// The base part's own switch, under a different word from the layer's:
// `enabled` in this block has always meant the layer, and a part that is
// out of the build is `hidden` wherever it is written, base or not.
if layer.base().hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, layer.base());
for (op, param, value) in layer.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
}
write_coverage(out, layer.base());
for part in &layer.parts()[1..] {
write_part(out, version, &layer.id, part);
}
}
/// Write one part of a layer's mask as its own block.
///
/// Its own block rather than a nested key, because a merge compares lines and
/// a part is the granularity a photographer edits: adding a correction to a
/// mask should read, in a diff, as a correction added — not as the whole mask
/// having been rewritten. The version and the layer are both named in the
/// header for the reason [`write_mask`] names the version: "belongs to
/// whatever appeared above me" is a relationship that does not survive a hand
/// edit or a merge.
fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) {
let _ = write!(out, "\n[part {version} {layer} {}]\n", part.id);
let _ = writeln!(out, "join = {}", part.join.name());
write_source(out, part);
if part.invert {
let _ = writeln!(out, "invert = 1");
}
if part.hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, part);
write_coverage(out, part);
}
/// The `source = …` line and whatever else that kind of source needs.
fn write_source(out: &mut String, part: &MaskPart) {
let _ = writeln!(out, "source = {}", part.source.kind());
match &part.source {
MaskSource::Regions {
signature,
level,
ids,
} => {
let _ = writeln!(out, "signature = {signature}");
let _ = writeln!(out, "level = {level}");
// One space-separated line rather than a key per id: a selection
// is a few hundred numbers, and three hundred lines of
// `region.7 = 1` would bury the rest of the file.
let list: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
let _ = writeln!(out, "regions = {}", list.join(" "));
}
MaskSource::Subject {
signature,
index,
class,
score,
} => {
let _ = writeln!(out, "signature = {signature}");
let _ = writeln!(out, "index = {index}");
let _ = writeln!(out, "class = {class}");
let _ = writeln!(out, "score = {}", format_value(*score));
}
MaskSource::Category { signature, name } => {
let _ = writeln!(out, "signature = {signature}");
let _ = writeln!(out, "category = {name}");
}
MaskSource::Linear {
centre,
angle,
width,
} => {
let _ = writeln!(
out,
"centre = {} {}",
format_value(centre.0),
format_value(centre.1)
);
let _ = writeln!(out, "angle = {}", format_value(*angle));
let _ = writeln!(out, "width = {}", format_value(*width));
}
MaskSource::Radial {
centre,
radii,
angle,
feather,
} => {
let _ = writeln!(
out,
"centre = {} {}",
format_value(centre.0),
format_value(centre.1)
);
let _ = writeln!(
out,
"radii = {} {}",
format_value(radii.0),
format_value(radii.1)
);
let _ = writeln!(out, "angle = {}", format_value(*angle));
let _ = writeln!(out, "feather = {}", format_value(*feather));
}
MaskSource::Brush { strokes } => write_strokes(out, strokes),
// TRACES: FR-DEV-10
// `band` rather than a key per edge, and the same key for both range
// sources. The two bounds and their softness are one quantity to a
// reader — "the band this mask selects" — and splitting them over
// three lines makes a hand edit that moves one edge and forgets the
// other look like a file that was written that way.
MaskSource::Luminance { lo, hi, softness } => {
let _ = writeln!(
out,
"band = {} {} {}",
format_value(*lo),
format_value(*hi),
format_value(*softness)
);
}
MaskSource::Colour {
hue,
hue_width,
chroma_lo,
chroma_hi,
softness,
} => {
// The chroma half goes out as `band` so that the two range
// sources share a key for the same idea, and the arc gets its
// own: a hue is a position on a circle and its neighbours are not
// bounds in the sense the chroma pair are.
let _ = writeln!(
out,
"hue = {} {}",
format_value(*hue),
format_value(*hue_width)
);
let _ = writeln!(
out,
"band = {} {} {}",
format_value(*chroma_lo),
format_value(*chroma_hi),
format_value(*softness)
);
}
}
}
/// The edge treatment, written only where it is not the default — the same
/// rule the parameters follow, so a file stays readable and a mask nobody has
/// fiddled with contributes four fewer lines.
fn write_shaping(out: &mut String, part: &MaskPart) {
if part.feather != DEFAULT_FEATHER {
let _ = writeln!(out, "edge-feather = {}", format_value(part.feather));
}
if part.falloff != Falloff::default() {
let _ = writeln!(out, "edge-falloff = {}", part.falloff.name());
}
if part.morphology != Morphology::default() {
let _ = writeln!(out, "morphology = {}", part.morphology.name());
let _ = writeln!(out, "morph-radius = {}", format_value(part.morph_radius));
}
// Absent means zero, which is the model's own weighting — so a file
// written before this control existed reads back looking exactly as it did.
if part.refine != 0.0 {
let _ = writeln!(out, "refine = {}", format_value(part.refine));
}
}
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The pixels a model found, so that opening the photograph again — or
/// exporting it from the grid, where no model is ever run — renders the part
/// instead of silently dropping it. See [`crate::coverage`] for the encoding
/// and for why it is one line.
///
/// **Last in the block, and that is on purpose.** It is thousands of
/// characters against a dozen elsewhere, and a sidecar is read by hand when an
/// edit has gone wrong (ARCH §6.12); everything a human is looking for should
/// be above it rather than after it.
///
/// Omitted, not truncated, when it will not encode: a part with no stored
/// coverage behaves exactly as every layer did before this existed, which is a
/// mask that needs the model run — where a *partial* one would be a mask that
/// is confidently wrong.
fn write_coverage(out: &mut String, part: &MaskPart) {
if let Some(coverage) = part.coverage.as_ref() {
let _ = writeln!(out, "coverage = {}", coverage.to_text());
}
}
/// Write a brush layer's strokes, one line each.
///
/// A line per stroke, in the order they were painted, because the order *is*
/// the mask: an erase after an add removes it and the same pair reversed does
/// not. It is also the granularity anyone reading a diff wants — a stroke is
/// what the user made and what an undo takes back. A line per point would bury
/// the rest of the file, and one line for the whole layer would make adding a
/// stroke look like the entire mask had been rewritten.
///
/// Points are `x,y` pairs rather than a flat run of numbers. A truncated or
/// hand-edited line would otherwise shift every coordinate by one and land the
/// mask somewhere else entirely, which is the failure that looks like the
/// software forgot the edit rather than like a damaged file.
fn write_strokes(out: &mut String, strokes: &[Stroke]) {
for stroke in strokes {
let _ = write!(
out,
"stroke = {} {} {} {}",
if stroke.erase { "erase" } else { "add" },
format_value(stroke.radius),
format_value(stroke.hardness),
format_value(stroke.flow),
);
for (x, y) in &stroke.points {
let _ = write!(out, " {},{}", format_value(*x), format_value(*y));
}
let _ = writeln!(out);
}
}
/// Read one `stroke = …` line, or nothing if it cannot be trusted.
///
/// A malformed stroke costs that stroke and not the layer. Refusing the whole
/// block would throw away every other stroke on it over one bad line, and
/// guessing at the missing half would put paint somewhere the user never
/// touched — which of the three is worst depends on the line, but a wrong mask
/// is the only one that looks like it worked.
fn parse_stroke(value: &str) -> Option<Stroke> {
let mut tokens = value.split_whitespace();
let erase = match tokens.next()? {
"add" => false,
"erase" => true,
other => {
log::warn!("sidecar: stroke is neither add nor erase ('{other}'); ignoring it");
return None;
}
};
let radius: f32 = tokens.next()?.parse().ok()?;
let hardness: f32 = tokens.next()?.parse().ok()?;
let flow: f32 = tokens.next()?.parse().ok()?;
if !(radius.is_finite() && hardness.is_finite() && flow.is_finite()) {
return None;
}
let mut stroke = Stroke::new(erase, radius, hardness, flow);
for token in tokens {
let (x, y) = token.split_once(',')?;
let (x, y) = (x.parse::<f32>().ok()?, y.parse::<f32>().ok()?);
if !(x.is_finite() && y.is_finite()) {
return None;
}
// Straight onto the list rather than through `push_point`, which drops
// a point too close to the last: that rule belongs to a finger being
// dragged, and applying it here would quietly rewrite a stroke every
// time the file was read — so a sidecar would not survive its own round
// trip, and two devices would rewrite each other's masks forever.
stroke.points.push((x, y));
}
(!stroke.is_empty()).then_some(stroke)
}
/// One part of a mask being read, before it is complete enough to be a part.
///
/// Separate from [`MaskPart`] because the source cannot be built until every
/// one of its fields has been seen, and the fields arrive one line at a time
/// in whatever order the writer chose.
struct PartialPart {
id: String,
join: Join,
kind: String,
signature: u64,
level: u32,
ids: Vec<u32>,
index: u32,
class: String,
category: String,
score: f32,
centre: (f32, f32),
radii: (f32, f32),
angle: f32,
width: f32,
feather: f32,
/// TRACES: FR-DEV-10
/// A range source's band: its two bounds and the fade at each edge.
///
/// One triple for both range kinds — tone positions for a luminance
/// part, chroma for a colour one — because they are the same line in the
/// file and reading them into two sets of fields would be two ways to
/// spell one thing.
band: (f32, f32, f32),
/// A colour range's arc: centre and half-width, in turns.
hue: (f32, f32),
invert: bool,
hidden: bool,
/// The *part's* edge transition, distinct from the radial source's own
/// `feather` above — different quantity, different units, different key.
edge_feather: f32,
falloff: Falloff,
morphology: Morphology,
morph_radius: f32,
refine: f32,
coverage: Option<Coverage>,
strokes: Vec<Stroke>,
}
impl PartialPart {
fn new(id: &str) -> Self {
Self {
id: id.to_string(),
join: Join::default(),
kind: String::new(),
signature: 0,
level: 0,
ids: Vec::new(),
index: 0,
category: String::new(),
class: String::new(),
score: 0.0,
centre: (0.5, 0.5),
radii: (0.25, 0.25),
angle: 0.0,
width: 0.0,
feather: 0.0,
// The defaults a new layer gets, so a block that names a range
// source and nothing else reads back as the mask the panel would
// have made rather than as an empty band selecting nothing.
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
hue: (0.06, 0.05),
invert: false,
hidden: false,
edge_feather: DEFAULT_FEATHER,
falloff: Falloff::default(),
morphology: Morphology::default(),
morph_radius: 0.0,
refine: 0.0,
coverage: None,
strokes: Vec::new(),
}
}
/// Take one key, returning whether it was one of this part's.
fn set(&mut self, key: &str, value: &str) -> bool {
match key {
"source" => self.kind = value.to_string(),
"signature" => self.signature = value.parse().unwrap_or(0),
"level" => self.level = value.parse().unwrap_or(0),
"regions" => {
self.ids = value
.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect();
// Sorted and deduplicated on the way in rather than trusted
// from the file: the mask's identity is the *set*, and a
// hand-edited or merged line arriving out of order would
// otherwise be a different cache key for the same selection.
self.ids.sort_unstable();
self.ids.dedup();
}
// A category's own key rather than reusing `class`: both name a
// thing the mask covers, but one is a COCO instance's label and
// the other an entry in the scene descriptor, and a file that
// conflated them would round-trip a subject into a category.
"category" => self.category = value.to_string(),
"index" => self.index = value.parse().unwrap_or(0),
"class" => self.class = value.to_string(),
"score" => self.score = value.parse().unwrap_or(0.0),
"centre" => self.centre = pair(value).unwrap_or(self.centre),
"radii" => self.radii = pair(value).unwrap_or(self.radii),
"angle" => self.angle = value.parse().unwrap_or(0.0),
"width" => self.width = value.parse().unwrap_or(0.0),
"feather" => self.feather = value.parse().unwrap_or(0.0),
// TRACES: FR-DEV-10
// Kept as written and ordered later, in `MaskSource::*_range`.
// Clamping here as well would be a second place the band's
// invariants live, and the two would drift.
"band" => self.band = triple(value).unwrap_or(self.band),
"hue" => self.hue = pair(value).unwrap_or(self.hue),
// A cache, so an unreadable one is dropped rather than refused:
// the layer still says what it selects, and the worst a `None`
// here costs is a model run. Refusing the layer over it would
// throw away an edit to protect a copy of something reproducible.
"coverage" => {
self.coverage = Coverage::parse(value);
if self.coverage.is_none() {
log::warn!(
"sidecar: mask {} has unreadable coverage; it will need the model run",
self.id
);
}
}
// Appended rather than assigned: a brush layer is a list of these,
// and the file's line order is the order they were painted in.
"stroke" => self.strokes.extend(parse_stroke(value)),
"invert" => self.invert = value != "0",
// Absent means shown, so a file from before the switch existed
// reads back with every part in the build, as it was written.
"hidden" => self.hidden = value != "0",
// Clamped, not trusted: a feather wider than the frame is not a
// mask, and a negative one is a distance field read backwards.
"edge-feather" => {
self.edge_feather = value
.parse::<f32>()
.unwrap_or(DEFAULT_FEATHER)
.clamp(0.0, 1.0)
}
"morph-radius" => {
self.morph_radius = value.parse::<f32>().unwrap_or(0.0).clamp(0.0, 1.0)
}
// Clamped for the same reason the feather is: a strictness off the
// end of the scale is a category that has deleted itself, and a
// file is not trusted to ask for that.
"refine" => {
self.refine = value
.parse::<f32>()
.unwrap_or(0.0)
.clamp(0.0, crate::mask::MAX_REFINE)
}
// An unrecognised name falls back to the default rather than
// dropping the layer. A newer build's falloff curve is a cosmetic
// difference in the edge; losing the selection under it would not
// be cosmetic.
"edge-falloff" => {
self.falloff = Falloff::from_name(value).unwrap_or_else(|| {
log::warn!("sidecar: unknown falloff '{value}'; using the default");
Falloff::default()
})
}
"morphology" => {
self.morphology = Morphology::from_name(value).unwrap_or_else(|| {
log::warn!("sidecar: unknown morphology '{value}'; using none");
Morphology::default()
})
}
// Not one of ours. The caller decides what that means: an
// adjustment's parameter inside the mask block, or a key nothing
// in this build understands.
_ => return false,
}
true
}
/// Build the part, or `None` if the source kind is one this build has
/// never heard of — a newer format's mask type, which is skipped rather
/// than guessed at.
fn finish(self) -> Option<MaskPart> {
let source = match self.kind.as_str() {
"regions" => MaskSource::Regions {
signature: self.signature,
level: self.level,
ids: self.ids,
},
"subject" => MaskSource::Subject {
signature: self.signature,
index: self.index,
class: self.class,
score: self.score,
},
"category" => MaskSource::Category {
signature: self.signature,
name: self.category,
},
"linear" => MaskSource::Linear {
centre: self.centre,
angle: self.angle,
width: self.width,
},
"radial" => MaskSource::Radial {
centre: self.centre,
radii: self.radii,
angle: self.angle,
feather: self.feather,
},
"brush" => MaskSource::Brush {
strokes: self.strokes,
},
// TRACES: FR-DEV-10
// Through the constructors, not built by hand: they are where the
// band's invariants are kept, and a file is exactly the input
// that has not been through them.
"luminance" => MaskSource::luminance_range(self.band.0, self.band.1, self.band.2),
"colour" => MaskSource::colour_range(
self.hue.0,
self.hue.1,
self.band.0,
self.band.1,
self.band.2,
),
other => {
log::warn!(
"sidecar: unknown mask source '{other}'; skipping part {}",
self.id
);
return None;
}
};
let mut part = MaskPart::new(self.id, self.join, source);
part.invert = self.invert;
part.hidden = self.hidden;
part.feather = self.edge_feather;
part.falloff = self.falloff;
part.morphology = self.morphology;
part.morph_radius = self.morph_radius;
part.refine = self.refine;
// Only where there is a model behind the part to have produced it. A
// gradient or a brush that arrived carrying one is a file that has
// been hand-edited or written by a build that means something else by
// the key, and honouring it would upload a raster nothing samples.
part.coverage = match part.source {
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
self.coverage.map(std::sync::Arc::new)
}
_ => None,
};
Some(part)
}
}
/// A mask block being read, before it is complete enough to be a layer.
///
/// Holds what belongs to the *layer* — its name, its inversion, its opacity,
/// its adjustments — and one [`PartialPart`] per selection the mask is built
/// from. `parts[0]` is filled by the mask block's own keys, which is what
/// makes a file written before parts existed read back as a layer of one.
struct PartialMask {
version: String,
id: String,
name: String,
invert: bool,
opacity: f32,
enabled: bool,
params: Vec<(String, String, f32)>,
parts: Vec<PartialPart>,
/// Whether a `[part]` block is open, so `invert` can be told apart: in the
/// mask block it flips the finished mask, and in a part block it flips
/// that part before it is joined. Same word, two controls, and the block
/// is the only thing that distinguishes them.
in_part: bool,
}
impl PartialMask {
fn new(version: &str, id: &str) -> Self {
Self {
version: version.to_string(),
id: id.to_string(),
name: String::new(),
invert: false,
opacity: 1.0,
enabled: true,
params: Vec::new(),
parts: vec![PartialPart::new(crate::mask::FIRST_PART_ID)],
in_part: false,
}
}
/// Open a `[part]` block. Every key from here to the next block header
/// belongs to it.
fn begin_part(&mut self, id: &str) {
self.parts.push(PartialPart::new(id));
self.in_part = true;
}
fn set(&mut self, key: &str, value: &str) {
// The layer's own keys, and only while no part block is open. A part
// has an `invert` of its own and no opacity at all, so which of the
// two an `invert` line means is decided by the block it is in.
if !self.in_part {
match key {
"name" => {
self.name = value.to_string();
return;
}
"invert" => {
self.invert = value != "0";
return;
}
"opacity" => {
self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
return;
}
"enabled" => {
self.enabled = value != "0";
return;
}
_ => {}
}
} else if key == "join" {
// Unknown falls back to a union rather than dropping the part: a
// join this build does not have is a part that joins some other
// way, and adding it is the reading that keeps the selection
// visible and therefore fixable. Silently subtracting under a name
// nobody could see would not be.
if let Some(part) = self.parts.last_mut() {
part.join = Join::from_name(value).unwrap_or_else(|| {
log::warn!("sidecar: unknown join '{value}'; adding the part instead");
Join::Union
});
}
return;
}
if let Some(part) = self.parts.last_mut() {
if part.set(key, value) {
return;
}
}
// Whatever is left is an adjustment, which belongs to the layer
// however deep in the block it was written.
match (key.split_once('.'), value.parse::<f32>()) {
(Some((op, param)), Ok(v)) if v.is_finite() => {
self.params.push((op.to_string(), param.to_string(), v));
}
_ => log::warn!("sidecar: unreadable mask key {key}; ignoring"),
}
}
/// Build the layer, or `None` when its base part cannot be built — a
/// newer format's mask type, which is skipped rather than guessed at.
///
/// A *later* part that cannot be built costs that part and not the layer,
/// on the rule [`parse_stroke`] already follows: the rest of the mask is
/// work the photographer did, and throwing it away to protect the
/// consistency of one correction loses more than it saves.
fn finish(self) -> Option<(String, MaskLayer)> {
let id = self.id;
let mut parts = Vec::new();
for (i, part) in self.parts.into_iter().enumerate() {
match (part.finish(), i) {
(Some(p), _) => parts.push(p),
(None, 0) => return None,
(None, _) => {}
}
}
let mut layer = MaskLayer::from_parts(id, parts)?;
layer.name = self.name;
layer.invert = self.invert;
layer.opacity = self.opacity;
layer.enabled = self.enabled;
for (op, param, value) in &self.params {
// `ParamId` holds a `&'static str` and this one came off disk, so
// it is matched against the descriptors and the *static* id is
// what reaches the operation — exactly what `resolve` does for the
// global chain.
let Some(id) = layer
.ops
.iter()
.find(|o| o.descriptor().id.0 == op)
// The descriptor is bound inside the closure rather than
// chained through: it is an owned `Arc` now, so a
// `ParamDescriptor` borrowed out of it would not outlive the
// expression. The `ParamId` is `Copy`, so it does.
.and_then(|o| {
o.descriptor()
.params
.iter()
.find(|p| p.id.0 == param)
.map(|p| p.id)
})
else {
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
continue;
};
layer.set_param(op, id, *value);
}
Some((self.version, layer))
}
}
/// Two whitespace-separated floats.
fn pair(value: &str) -> Option<(f32, f32)> {
let mut it = value.split_whitespace();
let a: f32 = it.next()?.parse().ok()?;
let b: f32 = it.next()?.parse().ok()?;
// A NaN parses — `"nan"` is a valid float — and then survives every clamp
// downstream, because every comparison against it is false. It reaches a
// uniform, and a mask whose geometry or band is NaN selects nothing while
// looking, in the file and in the panel, exactly like one that should.
(a.is_finite() && b.is_finite()).then_some((a, b))
}
/// TRACES: FR-DEV-10
/// Three whitespace-separated floats — a range mask's band.
fn triple(value: &str) -> Option<(f32, f32, f32)> {
let mut it = value.split_whitespace();
let a: f32 = it.next()?.parse().ok()?;
let b: f32 = it.next()?.parse().ok()?;
let c: f32 = it.next()?.parse().ok()?;
(a.is_finite() && b.is_finite() && c.is_finite()).then_some((a, b, c))
}
/// Format a value without a trailing `.0` on whole numbers, and without
/// exponent notation — both so the file stays diffable and hand-readable.
fn format_value(v: f32) -> String {
let mut s = format!("{v:.6}");
if s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
if s == "-0" {
s = "0".to_string();
}
s
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
/// The header line was missing or not a `drsc` header.
NotASidecar,
/// Written by a newer build, in a format this one cannot read.
UnsupportedVersion(u32),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotASidecar => f.write_str("not a DarkRoom sidecar"),
Self::UnsupportedVersion(v) => {
write!(f, "sidecar format version {v} is newer than this build")
}
}
}
}
impl std::error::Error for ParseError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::framing;
use crate::ops::{curve, exposure, saturation, white_balance};
fn edited() -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
g
}
fn version_of(graph: &EditGraph) -> Version {
Version::from_graph("uuid-1", "Default", graph)
}
/// A graph developing on a stock, with tables well-formed enough for the
/// film node to keep them. The emulsion is invented; what is under test
/// is whether the *choice* reaches the file.
fn on_film(stock: &str) -> EditGraph {
let mut g = edited();
g.set_film(Some(crate::graph::Film {
stock: stock.to_string(),
print: None,
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}));
g
}
#[test]
fn saving_an_edit_keeps_the_film_it_was_developed_on() {
// `update` is the write path — the one an automatic save goes through
// — and it used to copy the parameters and the masks and say nothing
// about the film. A photograph developed on a stock was written back
// without it, so the next time it opened, the emulsion was gone and
// nothing had reported a failure.
//
// It reads as an oversight because it was one, and that is the point:
// three routines captured "the edit" and each captured a different
// subset. All three now destructure one `EditState`, so the next part
// of an edit cannot be forgotten by anybody writing a line too few.
let mut v = version_of(&on_film("kodak_portra_400"));
v.film = None;
v.update(&on_film("kodak_portra_400"), "device-a", 1000);
assert_eq!(
v.film,
Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
}),
"the stock did not survive the save"
);
}
#[test]
fn a_film_written_by_update_comes_back_off_the_disk() {
// End to end, because the field being set is only half of it: the
// stock has to reach the text and parse back out of it.
let mut v = version_of(&EditGraph::default_chain());
v.update(&on_film("ilford_hp5"), "device-a", 1000);
let mut sidecar = Sidecar::new();
sidecar.put(v);
let parsed = Sidecar::parse(&sidecar.to_text()).expect("re-read");
let mut graph = EditGraph::default_chain();
let rebake = parsed
.default_version()
.expect("a version")
.apply(&mut graph);
assert_eq!(
rebake.wanted().map(|f| f.stock.as_str()),
Some("ilford_hp5"),
"reopening the photograph has to ask for its stock back"
);
}
#[test]
fn only_non_default_values_are_written() {
// The property the whole format rests on: a neutral operation is
// absent, so a file stays small and an operation added later reads
// as neutral rather than as missing.
let v = version_of(&edited());
assert_eq!(v.params.len(), 2);
assert!(v
.params
.contains_key(&("exposure".into(), "exposure".into())));
assert!(!v.params.keys().any(|(op, _)| op == saturation::ID.0));
}
#[test]
fn a_neutral_graph_writes_no_parameters() {
let v = version_of(&EditGraph::default_chain());
assert!(v.params.is_empty());
}
#[test]
fn a_graph_round_trips_through_text() {
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&edited()));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
assert_eq!(
restored.param(white_balance::ID, white_balance::TEMPERATURE),
Some(30.0)
);
assert!(!restored.is_neutral());
}
#[test]
fn every_parameter_in_the_chain_round_trips() {
// The generic claim, asserted against the whole chain rather than a
// sample: if an operation needed special handling to persist, this
// is where it would fail.
let mut g = EditGraph::default_chain();
for cap in g.capabilities() {
for p in &cap.params {
if let crate::ParamKind::Scalar { max, precision, .. } = p.kind {
let step = 10f32.powi(i32::from(precision));
let target = (max * 0.5 * step).round() / step;
g.set_param(cap.id, p.id, target);
}
}
}
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&g));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored)
.expect_no_film();
for cap in g.capabilities() {
for p in &cap.params {
assert_eq!(
restored.param(cap.id, p.id),
Some(p.value),
"{}.{} did not survive the sidecar",
cap.id,
p.id
);
}
}
}
#[test]
fn framing_survives_the_round_trip() {
// Framing is not an `Operation`, so it is exactly the stage a
// serialiser written against the op list alone would silently drop.
let mut g = EditGraph::default_chain();
g.set_crop(crate::CropRect {
x: 0.1,
y: 0.2,
width: 0.5,
height: 0.6,
});
g.set_param(framing::ID, framing::ANGLE, -1.5);
g.rotate_quarters(1);
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&g));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.crop(), g.crop());
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
}
#[test]
fn a_sidecar_neither_records_nor_erases_the_files_orientation() {
// How a file stored its pixels is a fact about the file, so it must
// not travel in the sidecar — a shared edit would then carry one
// camera's sensor scan onto another's. Two failures are checked
// together because they are the same mistake seen from each end.
let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6));
// Nothing was edited, so there is nothing to write. If the baseline
// leaked into `param`, a rotation would appear here.
let v = version_of(&sideways);
let text = {
let mut s = Sidecar::new();
s.put(v);
s.to_text()
};
assert!(
!text.contains("framing.rotation"),
"an untouched sideways file wrote a rotation:\n{text}"
);
// And applying an edit — which resets the graph first — must leave the
// orientation where it was, or reopening an edited portrait frame
// shows it on its side.
let mut edited = EditGraph::default_chain();
edited.set_param(framing::ID, framing::ANGLE, -1.5);
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&edited));
Sidecar::parse(&sidecar.to_text())
.expect("valid")
.default_version()
.expect("a version")
.apply(&mut sideways)
.expect_no_film();
assert_eq!(
sideways.framing().baseline(),
dr_types::Orientation::from_exif(6)
);
assert_eq!(sideways.output_size(6000, 4000), (4000, 6000));
}
#[test]
fn applying_a_version_replaces_rather_than_overlays() {
// Loading an edit onto a graph that already holds one must not leave
// the previous image's exposure behind.
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&EditGraph::default_chain()));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut g = edited();
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert!(g.is_neutral(), "a neutral version must clear the graph");
}
#[test]
fn writing_the_same_state_twice_is_byte_identical() {
// What lets a caller skip an upload by comparing content.
let mut a = Sidecar::new();
a.put(version_of(&edited()));
let once = a.to_text();
let twice = Sidecar::parse(&once).expect("valid").to_text();
assert_eq!(once, twice);
}
/// TRACES: FR-DEV-3 | FR-CAT-8
/// A file written before the tone curve had per-channel curves.
///
/// Spelled out as literal text rather than produced by `to_text`, because
/// the claim is about *those bytes*: a sidecar generated by this build
/// would agree with this build by construction, and would go on agreeing
/// with it through a rename that broke every file on disk.
#[test]
fn provenance_round_trips_at_the_top_level() {
// TRACES: FR-MRG-6
let text = "drsc 1\nderived_from = 2025/_MG_8320.CR2\nderived_from = 2025/_MG_8321.CR2\n\
merge = panorama cylindrical 49.7mm 2 frames\n\n[version u1]\nname = Default\n\
revision = 1\nmodified = 0\n";
let sidecar = Sidecar::parse(text).expect("parses");
assert_eq!(
sidecar.derived_from,
vec!["2025/_MG_8320.CR2", "2025/_MG_8321.CR2"]
);
assert_eq!(
sidecar.merge.as_deref(),
Some("panorama cylindrical 49.7mm 2 frames")
);
let out = sidecar.to_text();
assert!(out.starts_with("drsc 1\nderived_from = 2025/_MG_8320.CR2\n"));
assert_eq!(Sidecar::parse(&out).expect("re-parses"), sidecar);
}
#[test]
fn a_sidecar_from_before_the_channel_curves_still_names_the_master() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 4\nmodified = 9\n\
tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
// The S-curve the file describes, on the master curve and nowhere
// else.
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
for channel in [
curve::Channel::Red,
curve::Channel::Green,
curve::Channel::Blue,
] {
for point in 0..curve::POINTS {
for axis in [curve::Axis::X, curve::Axis::Y] {
let id = curve::coordinate(channel, point, axis);
let expected = g
.capabilities()
.iter()
.find(|c| c.id == curve::ID)
.and_then(|c| c.params.iter().find(|p| p.id == id))
.map(|p| p.default);
assert_eq!(
g.param(curve::ID, id),
expected,
"{id} moved, and no line in the file mentions it"
);
}
}
}
// And writing it back produces the same two lines: the curves the file
// never mentioned are still at their defaults, so they are still
// absent (`only_non_default_values_are_written`).
let written = Sidecar::parse(&Sidecar::parse(text).expect("valid").to_text())
.expect("valid")
.to_text();
assert!(written.contains("tone_curve.p1_y = 0.15"), "{written}");
assert!(written.contains("tone_curve.p3_y = 0.85"), "{written}");
assert!(
!written.contains("tone_curve.r_"),
"an untouched channel curve was written out:\n{written}"
);
}
/// TRACES: FR-DEV-3
/// The other direction: the new curves persist like any other parameter.
#[test]
fn a_per_channel_curve_survives_the_round_trip() {
let mut g = EditGraph::default_chain();
// A faded shadow: blue lifted at the black point, red pulled down.
let blue = curve::coordinate(curve::Channel::Blue, 0, curve::Axis::Y);
let red = curve::coordinate(curve::Channel::Red, 4, curve::Axis::Y);
g.set_param(curve::ID, blue, 0.08);
g.set_param(curve::ID, red, 0.92);
g.set_param(curve::ID, curve::P2_Y, 0.55);
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&g));
let text = sidecar.to_text();
// Keyed by the channel-prefixed id, which is what makes the master's
// unprefixed ones safe to leave alone.
assert!(text.contains("tone_curve.b_p0_y = 0.08"), "{text}");
let parsed = Sidecar::parse(&text).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(curve::ID, blue), Some(0.08));
assert_eq!(restored.param(curve::ID, red), Some(0.92));
assert_eq!(restored.param(curve::ID, curve::P2_Y), Some(0.55));
assert!(!restored.is_neutral());
}
/// TRACES: FR-PLG-8
/// FR-PLG-8's preservation clause, and only that clause.
///
/// The requirement opens by saying the sidecar "already preserves lines it
/// does not understand verbatim and writes them back untouched", and that
/// the property "is now load-bearing and shall be treated as such". This is
/// the test that makes it load-bearing rather than incidental.
///
/// **It is weaker than the requirement's stated acceptance criterion**,
/// which is that the re-saved file be *byte-identical* to the original.
/// This asserts `contains`. The two halves of that criterion exist
/// separately — `writing_the_same_state_twice_is_byte_identical` proves
/// byte identity for content this build understands, and this proves
/// survival for content it does not — and nothing yet joins them into the
/// one claim FR-PLG-8 makes. Everything else the requirement asks for
/// (aggregated alerts, the export gate, rendering without rather than
/// guessing) is unbuilt, which is why the tag is on these two tests and
/// not on the module.
#[test]
fn an_unknown_operation_survives_a_round_trip() {
// The data-loss case that matters: a device running an older build
// opens a file written by a newer one, saves, and must not delete
// the operation it never understood.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 3\nmodified = 5\n\
exposure.exposure = 0.5\ntime_machine.year = 1994\n";
let parsed = Sidecar::parse(text).expect("valid");
let written = parsed.to_text();
assert!(
written.contains("time_machine.year = 1994"),
"an unknown operation must not be dropped:\n{written}"
);
}
/// TRACES: FR-PLG-8
/// The other half of preservation: kept in the file, kept out of the edit.
///
/// "Render without, never render a guess" is a separate clause, and this is
/// not it — that one is about a *recognised* operation whose plugin is
/// missing. This is the narrower claim that an unparsed line cannot reach
/// the graph by accident, which is what makes preserving it safe.
#[test]
fn an_unknown_operation_does_not_reach_the_graph() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
time_machine.year = 1994\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert!(g.is_neutral());
}
#[test]
fn a_corrupt_value_costs_its_line_and_not_the_file() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!(
g.param(white_balance::ID, white_balance::TEMPERATURE),
Some(20.0)
);
}
#[test]
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
// A sidecar written by a build with a wider range must not put an
// out-of-range value into a uniform.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
exposure.exposure = 99\n";
let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut g)
.expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
#[test]
fn a_newer_format_version_is_refused_rather_than_guessed() {
let err = Sidecar::parse("drsc 99\n").unwrap_err();
assert_eq!(err, ParseError::UnsupportedVersion(99));
}
#[test]
fn a_non_sidecar_is_rejected() {
assert_eq!(
Sidecar::parse("<?xml version=\"1.0\"?>").unwrap_err(),
ParseError::NotASidecar
);
}
#[test]
fn several_versions_are_kept_apart() {
// FR-CAT-12: one image, several virtual copies, independent edits.
let mut sidecar = Sidecar::new();
let mut a = Version::from_graph("u1", "Colour", &edited());
a.is_default = true;
let mut mono = EditGraph::default_chain();
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
sidecar.put(a);
sidecar.put(Version::from_graph("u2", "Mono", &mono));
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
assert_eq!(parsed.versions.len(), 2);
assert_eq!(parsed.default_version().expect("default").name, "Colour");
let mut g = EditGraph::default_chain();
parsed.versions["u2"].apply(&mut g).expect_no_film();
assert_eq!(
g.param(saturation::ID, saturation::SATURATION),
Some(-100.0)
);
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
}
/// TRACES: FR-DEV-5
/// A snapshot is a version with a pointer, and the pointer survives the
/// file: it comes back as a snapshot of the edit it was taken from, with
/// the edit it stored, and the photograph still opens at its default.
#[test]
fn a_snapshot_round_trips_as_a_snapshot_of_its_edit() {
let mut sidecar = Sidecar::new();
let mut current = Version::from_graph("u1", "Default", &edited());
current.is_default = true;
sidecar.put(current);
let mut mono = EditGraph::default_chain();
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
let mut snapshot = Version::from_graph("snap-1", "Black and white", &mono);
snapshot.snapshot_of = Some("u1".into());
snapshot.modified = 7;
sidecar.put(snapshot);
let text = sidecar.to_text();
assert!(text.contains("snapshot-of = u1"), "{text}");
let parsed = Sidecar::parse(&text).expect("valid");
assert_eq!(parsed.default_version().expect("default").uuid, "u1");
let snapshots = parsed.snapshots_of("u1");
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].name, "Black and white");
assert!(snapshots[0].is_snapshot());
let mut g = EditGraph::default_chain();
snapshots[0].apply(&mut g).expect_no_film();
assert_eq!(
g.param(saturation::ID, saturation::SATURATION),
Some(-100.0)
);
}
/// TRACES: FR-DEV-5
/// A file whose only versions are snapshots has no edit to open, and
/// must not answer with one of the snapshots as if it were.
#[test]
fn a_snapshot_is_never_the_default() {
let mut sidecar = Sidecar::new();
let mut snapshot = Version::from_graph("snap-1", "Earlier", &edited());
snapshot.snapshot_of = Some("gone".into());
sidecar.put(snapshot);
assert!(sidecar.default_version().is_none());
}
/// TRACES: FR-DEV-5 | FR-NC-9
/// Writing a session's snapshots back removes what it deleted and keeps
/// what it never saw — another device's snapshot is not this device's to
/// remove by not knowing about it.
#[test]
fn replacing_snapshots_removes_only_what_was_deleted() {
let mut sidecar = Sidecar::new();
let mut current = Version::from_graph("u1", "Default", &edited());
current.is_default = true;
sidecar.put(current);
for id in ["mine-1", "mine-2", "theirs-1"] {
let mut v = Version::from_graph(id, id, &edited());
v.snapshot_of = Some("u1".into());
sidecar.put(v);
}
// This session loaded mine-1 and mine-2, deleted mine-2, took mine-3.
let mut kept = Version::from_graph("mine-1", "mine-1", &edited());
kept.snapshot_of = Some("u1".into());
let taken = Version::from_graph("mine-3", "mine-3", &edited());
sidecar.replace_snapshots("u1", vec![kept, taken], &["mine-2".to_string()]);
let ids: Vec<&str> = sidecar
.snapshots_of("u1")
.iter()
.map(|v| v.uuid.as_str())
.collect();
assert_eq!(ids, ["mine-1", "mine-3", "theirs-1"]);
assert_eq!(
sidecar.versions["mine-3"].snapshot_of.as_deref(),
Some("u1"),
"a snapshot taken without a pointer is pointed at the edit"
);
}
#[test]
fn update_bumps_the_revision() {
// FR-NC-9 resolves by revision; a write that did not bump it would
// lose to a stale remote.
let mut v = version_of(&EditGraph::default_chain());
let before = v.revision;
v.update(&edited(), "device-a", 1000);
assert_eq!(v.revision, before + 1);
assert_eq!(v.device, "device-a");
assert_eq!(v.modified, 1000);
}
#[test]
fn disjoint_edits_both_survive_a_merge() {
// FR-NC-9's motivating case, verbatim: a crop on one device and an
// exposure change on the other.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
local.update(&lg, "device-a", 100);
let mut remote = base.clone();
let mut rg = EditGraph::default_chain();
rg.set_crop(crate::CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 0.5,
});
remote.update(&rg, "device-b", 200);
let conflicts = local.merge(&remote, Some(&base));
assert!(conflicts.is_empty(), "disjoint edits must not conflict");
let mut g = EditGraph::default_chain();
local.apply(&mut g).expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
assert_eq!(g.crop().width, 0.5);
}
#[test]
fn a_genuine_conflict_resolves_to_the_higher_revision() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
local.update(&lg, "device-a", 100);
let mut remote = base.clone();
let mut rg = EditGraph::default_chain();
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
remote.update(&rg, "device-b", 200);
remote.revision = local.revision + 1;
let conflicts = local.merge(&remote, Some(&base));
assert_eq!(conflicts.len(), 1, "the same parameter, two values");
let mut g = EditGraph::default_chain();
local.apply(&mut g).expect_no_film();
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0));
}
#[test]
fn a_skewed_clock_cannot_beat_a_higher_revision() {
// Revision ahead of timestamp, deliberately: a device with a wrong
// clock must not silently overwrite real work.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
local.update(&lg, "device-a", 100);
local.revision = 50;
let mut remote = base.clone();
let mut rg = EditGraph::default_chain();
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
remote.update(&rg, "device-b", 999_999);
remote.revision = 2;
local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain();
local.apply(&mut g).expect_no_film();
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.0),
"the far-future timestamp must not win against a higher revision"
);
}
#[test]
fn a_remote_reset_to_default_removes_the_parameter() {
// Deletion is an edit too: clearing exposure on another device must
// propagate, not be masked by the key simply being absent.
let mut base = version_of(&edited());
base.revision = 1;
let mut local = base.clone();
let mut remote = base.clone();
remote.update(&EditGraph::default_chain(), "device-b", 200);
local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain();
local.apply(&mut g).expect_no_film();
assert!(g.is_neutral(), "the remote reset must survive the merge");
}
#[test]
fn a_merge_is_newer_than_either_input() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.revision = 4;
let mut remote = base.clone();
remote.revision = 9;
local.merge(&remote, Some(&base));
assert!(local.revision > 9, "a merged result must not look stale");
}
#[test]
fn a_film_choice_survives_the_round_trip() {
// TRACES: FR-DEV-3f
let mut v = version_of(&edited());
v.film = Some(FilmRef {
stock: "kodak_portra_400".into(),
print: Some("kodak_portra_endura".into()),
});
let mut side = Sidecar::default();
side.versions.insert(v.uuid.clone(), v);
let text = side.to_text();
let back = Sidecar::parse(&text).expect("parses");
let film = back.versions.values().next().unwrap().film.clone().unwrap();
assert_eq!(film.stock, "kodak_portra_400");
assert_eq!(film.print.as_deref(), Some("kodak_portra_endura"));
}
#[test]
fn a_film_with_no_print_round_trips_as_a_scan() {
// Absent paper is a *choice* — the film as it comes, which for a
// colour negative is the orange scan. It must not come back as the
// stock's default paper, or "show me the negative" would be
// unrepresentable.
let mut v = version_of(&edited());
v.film = Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
});
let mut side = Sidecar::default();
side.versions.insert(v.uuid.clone(), v);
let back = Sidecar::parse(&side.to_text()).expect("parses");
let film = back.versions.values().next().unwrap().film.clone().unwrap();
assert_eq!(film.stock, "kodak_portra_400");
assert_eq!(film.print, None);
}
#[test]
fn no_film_writes_no_film_line() {
// The same non-default rule the parameters and the rating follow: a
// library nobody has put on film does not grow a line per file.
let mut side = Sidecar::default();
let v = version_of(&edited());
side.versions.insert(v.uuid.clone(), v);
let text = side.to_text();
assert!(!text.contains("film"), "{text}");
}
#[test]
fn a_paper_line_above_its_film_line_is_not_lost() {
// Key order in a hand-edited file is not guaranteed, and the reader
// builds the film from two separate lines.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
film_print = kodak_portra_endura\nfilm = kodak_portra_400\n";
let side = Sidecar::parse(text).expect("parses");
let film = side.versions.values().next().unwrap().film.clone().unwrap();
assert_eq!(film.stock, "kodak_portra_400");
assert_eq!(film.print.as_deref(), Some("kodak_portra_endura"));
}
#[test]
fn a_stock_this_build_does_not_have_still_round_trips() {
// A profile is a file a user can add. A device without it must hand
// the name back untouched rather than drop it, or syncing to an older
// phone would quietly un-develop the photograph.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
film = ilford_hp5_plus\n";
let side = Sidecar::parse(text).expect("parses");
assert!(
side.to_text().contains("film = ilford_hp5_plus"),
"{}",
side.to_text()
);
}
#[test]
fn the_film_resolves_wholesale_rather_than_field_by_field() {
// TRACES: FR-DEV-3f | FR-NC-9
// One decision with two names in it. Taking the stock from one device
// and the paper from the other would print a negative on a paper
// nobody chose for it -- a combination neither photographer asked for,
// and one that renders as a colour cast rather than as an obvious
// mistake.
let mut base = version_of(&edited());
base.revision = 1;
let mut local = base.clone();
local.film = Some(FilmRef {
stock: "kodak_portra_400".into(),
print: Some("kodak_portra_endura".into()),
});
local.revision = 2;
let mut remote = base.clone();
remote.film = Some(FilmRef {
stock: "kodak_kodachrome_64".into(),
print: None,
});
remote.revision = 9;
local.merge(&remote, Some(&base));
let film = local.film.clone().expect("a film survived");
assert_eq!(film.stock, "kodak_kodachrome_64");
assert_eq!(
film.print, None,
"the loser's paper was grafted onto the winner's stock"
);
}
#[test]
fn clearing_the_film_elsewhere_propagates() {
// Unlike a rating, a cleared film is an edit -- "develop this normally
// again" -- so None must travel. A rating's zero does not, because
// there "unset" and "set to zero" are indistinguishable; here the
// revision says which happened.
let mut base = version_of(&edited());
base.film = Some(FilmRef {
stock: "kodak_portra_400".into(),
print: None,
});
base.revision = 1;
let mut local = base.clone();
local.revision = 2;
let mut remote = base.clone();
remote.film = None;
remote.revision = 9;
local.merge(&remote, Some(&base));
assert_eq!(local.film, None, "a deliberate clear did not propagate");
}
#[test]
fn a_rating_survives_the_round_trip() {
// The durability requirement: the catalog is disposable (ARCH §6.12),
// so a cull that lives only there is a cull one `rm` destroys.
let mut v = version_of(&EditGraph::default_chain());
v.rating = 4;
v.flag = 1;
let mut sidecar = Sidecar::new();
sidecar.put(v);
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let back = parsed.default_version().expect("a version");
assert_eq!(back.rating, 4);
assert_eq!(back.flag, 1);
}
#[test]
fn an_unrated_image_writes_no_judgement_lines() {
// The non-default rule applied to judgement: a library that has never
// been culled must not grow two lines per file.
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&EditGraph::default_chain()));
let text = sidecar.to_text();
assert!(!text.contains("rating"), "{text}");
assert!(!text.contains("flag"), "{text}");
}
#[test]
fn judgement_travels_with_an_otherwise_neutral_edit() {
// Culling produces no pixel change at all, so this is the *normal*
// sidecar during a cull — not an edge case. A writer that skipped
// files with a neutral graph would drop every rating.
let mut v = version_of(&EditGraph::default_chain());
v.rating = 5;
let mut sidecar = Sidecar::new();
sidecar.put(v);
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let back = parsed.default_version().expect("a version");
assert_eq!(back.rating, 5);
assert!(back.params.is_empty(), "no edit, just a judgement");
}
#[test]
fn an_out_of_range_rating_in_a_file_is_clamped() {
// Written by a build with a wider scale, or hand-edited. A stored 9
// would sort above five stars and no filter would reach it.
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
rating = 9\nflag = 77\n";
let parsed = Sidecar::parse(text).expect("valid");
let v = parsed.default_version().expect("a version");
assert_eq!(v.rating, MAX_RATING);
assert_eq!(v.flag, MAX_FLAG);
}
#[test]
fn a_corrupt_rating_costs_its_line_and_not_the_file() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
rating = later\nexposure.exposure = 0.5\n";
let parsed = Sidecar::parse(text).expect("valid");
let v = parsed.default_version().expect("a version");
assert_eq!(v.rating, 0);
assert_eq!(
v.params.get(&("exposure".into(), "exposure".into())),
Some(&0.5),
"the rest of the edit still loads"
);
}
#[test]
fn a_rating_from_a_device_that_never_culled_does_not_erase_one() {
// The failure this merge rule exists to prevent: a tablet that synced
// before the cull holds 0, and must not wipe the desktop's afternoon
// of work merely by having a later revision.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.rating = 5;
local.revision = 2;
let mut remote = base.clone();
remote.rating = 0;
remote.revision = 99;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 5, "an unjudged remote must not erase a cull");
}
#[test]
fn a_rating_made_elsewhere_arrives_when_we_have_none() {
// The other direction: culling on the tablet must reach the desktop.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut remote = base.clone();
remote.rating = 3;
remote.flag = 1;
remote.revision = 5;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 3);
assert_eq!(local.flag, 1);
}
#[test]
fn two_devices_that_both_rated_resolve_by_revision() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.rating = 2;
local.revision = 3;
let mut remote = base.clone();
remote.rating = 5;
remote.revision = 9;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 5, "the higher revision wins a real conflict");
}
#[test]
fn a_lower_revision_does_not_overwrite_our_rating() {
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
local.rating = 5;
local.revision = 40;
let mut remote = base.clone();
remote.rating = 1;
remote.revision = 2;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 5);
}
#[test]
fn a_rating_and_an_edit_merge_independently() {
// Culling on a tablet while editing on a desktop is the whole point of
// the multi-device workflow (FR-CULL-7); neither may cost the other.
let base = version_of(&EditGraph::default_chain());
let mut local = base.clone();
let mut lg = EditGraph::default_chain();
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
local.update(&lg, "desktop", 100);
let mut remote = base.clone();
remote.rating = 4;
remote.revision = local.revision + 1;
local.merge(&remote, Some(&base));
assert_eq!(local.rating, 4, "the tablet's cull arrived");
let mut g = EditGraph::default_chain();
local.apply(&mut g).expect_no_film();
assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE),
Some(1.5),
"and the desktop's edit survived it"
);
}
#[test]
fn values_are_written_without_exponent_notation() {
// The file is meant to be readable when an edit goes wrong, and a
// sidecar is the authoritative store — so that case matters.
assert_eq!(format_value(0.0001), "0.0001");
assert_eq!(format_value(1.0), "1");
assert_eq!(format_value(-0.0), "0");
assert_eq!(format_value(0.75), "0.75");
}
}
/// TRACES: FR-NC-8 | FR-NC-9
/// Two devices, one photograph, two `default = 1` versions.
///
/// The state a library reaches when each device mints its own version uuid.
/// These cover both halves of the resulting failure: which version a reader
/// picks, and whether the two ever converge.
#[cfg(test)]
mod split_defaults {
use super::*;
/// A default version as a device that had never heard of the other would
/// write it.
fn default_version(uuid: &str, revision: u64, modified: i64, rating: u8) -> Version {
Version {
uuid: uuid.to_string(),
name: "Default".to_string(),
is_default: true,
revision,
modified,
rating,
..Default::default()
}
}
fn with(versions: Vec<Version>) -> Sidecar {
let mut s = Sidecar::new();
for v in versions {
s.put(v);
}
s
}
/// The observed failure, reduced. Both blocks are `default = 1`; the older
/// one sorts first by uuid, and the reader used to answer with it — so a
/// rating made on the second device was invisible on the first.
#[test]
fn the_newer_edit_wins_even_when_its_uuid_sorts_later() {
let s = with(vec![
default_version("0545c20a", 1, 1_787_337_630, 4),
default_version("679fe872", 2, 1_788_092_930, 1),
]);
assert_eq!(s.default_version().unwrap().uuid, "679fe872");
assert_eq!(s.default_version().unwrap().rating, 1);
}
/// The same file with the uuids the other way round. The answer must
/// follow the revision, not the ordering — otherwise the test above only
/// passes by luck.
#[test]
fn the_newer_edit_wins_even_when_its_uuid_sorts_earlier() {
let s = with(vec![
default_version("0545c20a", 2, 1_788_092_930, 1),
default_version("679fe872", 1, 1_787_337_630, 4),
]);
assert_eq!(s.default_version().unwrap().uuid, "0545c20a");
assert_eq!(s.default_version().unwrap().rating, 1);
}
/// A skewed clock must not beat a real edit — the FR-NC-8 rule, applied at
/// the reader as well as at the merge.
#[test]
fn a_later_timestamp_cannot_beat_a_higher_revision() {
let s = with(vec![
default_version("aaaa", 5, 1_000, 3),
default_version("bbbb", 2, 9_999_999, 1),
]);
assert_eq!(s.default_version().unwrap().rating, 3);
}
/// Fusing is what makes the two converge rather than merely choosing
/// between them: a parameter only the *losing* version holds survives,
/// because the merge is key-wise.
#[test]
fn fusing_keeps_the_disjoint_work_of_both_devices() {
let mut old = default_version("0545c20a", 1, 100, 4);
old.params
.insert(("exposure".into(), "exposure".into()), 0.75);
let mut new = default_version("679fe872", 2, 200, 0);
new.params
.insert(("saturation".into(), "saturation".into()), 0.5);
let mut s = with(vec![old, new]);
let uuid = s.fuse_default_versions(None).unwrap();
assert_eq!(s.versions.len(), 1, "the split must be closed");
let v = &s.versions[&uuid];
assert_eq!(
v.params.get(&("exposure".into(), "exposure".into())),
Some(&0.75),
"the older device's exposure was dropped"
);
assert_eq!(
v.params.get(&("saturation".into(), "saturation".into())),
Some(&0.5),
"the newer device's saturation was dropped"
);
// A judgement the loser holds and the winner does not is adopted, for
// the reason `merge_judgement` gives: a zero is "never judged".
assert_eq!(v.rating, 4);
}
/// A contested value resolves to the higher revision, not to whichever
/// uuid sorted first.
#[test]
fn fusing_resolves_a_contested_value_by_revision() {
let mut old = default_version("aaaa", 9, 100, 0);
old.params
.insert(("exposure".into(), "exposure".into()), 0.75);
let mut new = default_version("bbbb", 1, 999_999, 0);
new.params
.insert(("exposure".into(), "exposure".into()), -2.0);
let mut s = with(vec![old, new]);
let uuid = s.fuse_default_versions(None).unwrap();
assert_eq!(
s.versions[&uuid]
.params
.get(&("exposure".into(), "exposure".into())),
Some(&0.75),
"the higher revision must win"
);
}
/// Every device must reach the same file from the same input, or two of
/// them fuse and upload forever without converging.
#[test]
fn fusing_is_the_same_on_every_device() {
let versions = vec![
default_version("cccc", 1, 100, 2),
default_version("aaaa", 3, 300, 0),
default_version("bbbb", 2, 200, 0),
];
let mut one = with(versions.clone());
// The other device parsed the same file, so it holds the same set in a
// different insertion order.
let mut two = with(versions.into_iter().rev().collect());
one.fuse_default_versions(None);
two.fuse_default_versions(None);
assert_eq!(one.to_text(), two.to_text());
assert_eq!(one.versions.len(), 1);
}
/// Three or more versions must all fold in. The accumulator's revision
/// climbs as it merges, so folding a chain in ascending order would drop
/// everything after the second — which is why the fold goes into the
/// winner instead.
#[test]
fn a_third_device_is_not_lost_to_the_accumulators_rising_revision() {
let mut a = default_version("aaaa", 1, 100, 0);
a.params.insert(("a".into(), "a".into()), 1.0);
let mut b = default_version("bbbb", 2, 200, 0);
b.params.insert(("b".into(), "b".into()), 2.0);
let mut c = default_version("cccc", 3, 300, 0);
c.params.insert(("c".into(), "c".into()), 3.0);
let mut s = with(vec![a, b, c]);
let uuid = s.fuse_default_versions(None).unwrap();
let v = &s.versions[&uuid];
for key in ["a", "b", "c"] {
assert!(
v.params.contains_key(&(key.to_string(), key.to_string())),
"{key} was dropped by the fold"
);
}
}
/// The rename half. A lone default under a randomly minted uuid has to
/// move onto the derived one, or the next write matches nothing and adds a
/// third version instead of amending this one.
#[test]
fn a_lone_default_is_renamed_onto_the_canonical_uuid() {
let mut s = with(vec![default_version("random-v4", 1, 100, 3)]);
let uuid = s.fuse_default_versions(Some("derived")).unwrap();
assert_eq!(uuid, "derived");
assert_eq!(s.versions.len(), 1);
assert_eq!(s.versions["derived"].rating, 3, "the edit went with it");
}
/// A virtual copy (FR-CAT-12) that happens to hold the canonical uuid must
/// not be overwritten by the rename.
#[test]
fn a_named_version_is_not_destroyed_by_the_rename() {
let mut copy = default_version("derived", 1, 100, 5);
copy.is_default = false;
copy.name = "For print".to_string();
let mut s = with(vec![default_version("random-v4", 1, 100, 3), copy]);
let uuid = s.fuse_default_versions(Some("derived")).unwrap();
assert_eq!(uuid, "random-v4", "the rename must be declined");
assert_eq!(s.versions.len(), 2);
assert_eq!(s.versions["derived"].name, "For print");
}
/// Fusing a file that never needed it must not rewrite it — a sidecar that
/// changes on every read is a sidecar that uploads on every read.
#[test]
fn fusing_a_healthy_sidecar_changes_nothing() {
let mut s = with(vec![default_version("only", 4, 400, 2)]);
let before = s.to_text();
s.fuse_default_versions(None);
assert_eq!(s.to_text(), before);
}
/// Nothing to fuse is not a failure — most photographs have never been
/// edited on any device.
#[test]
fn an_empty_sidecar_has_no_default_to_fuse() {
let mut s = Sidecar::new();
assert_eq!(s.fuse_default_versions(Some("derived")), None);
assert!(s.versions.is_empty());
}
}