diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index f1bd0de..3d3968a 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -37,6 +37,7 @@ pub mod graph; pub mod lens; pub mod operation; pub mod ops; +pub mod sidecar; pub use descriptor::{ LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation, Scale, @@ -49,6 +50,7 @@ pub use operation::{ compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform, RESERVED_UNIFORM_FIELDS, }; +pub use sidecar::{Sidecar, Version}; #[cfg(test)] mod tests { diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs new file mode 100644 index 0000000..539e0af --- /dev/null +++ b/core/dr-pipeline/src/sidecar.rs @@ -0,0 +1,1093 @@ +//! 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::descriptor::{OpId, ParamId}; +use crate::graph::EditGraph; + +/// 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, + /// 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, +} + +/// 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, + /// 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>, + /// 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, +} + +impl Version { + /// A new version holding the non-default parameters of `graph`. + pub fn from_graph(uuid: impl Into, name: impl Into, graph: &EditGraph) -> Self { + Self { + uuid: uuid.into(), + name: name.into(), + is_default: false, + 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: capture(graph), + unknown: BTreeMap::new(), + } + } + + /// Apply this version's parameters to a graph. + /// + /// The graph is reset first, so 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. + pub fn apply(&self, graph: &mut EditGraph) { + graph.reset(); + for ((op, param), value) in &self.params { + // `OpId` and `ParamId` hold `&'static str` because descriptors + // are statics, and a sidecar's strings are not. `resolve` matches + // the file's names against the descriptors and hands back the + // static ids, so no string read from disk is ever leaked to get + // a lifetime it did not earn. + let Some((op, param)) = resolve(graph, op, param) else { + log::warn!("sidecar: unknown parameter {op}.{param}; ignoring"); + continue; + }; + graph.set_param(op, param, *value); + } + } + + /// 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) { + self.params = capture(graph); + self.revision = self.revision.saturating_add(1); + self.device = device.to_string(); + self.modified = now; + } + + /// 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::>() + .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); + + // 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 + } + } + } +} + +/// Every non-default parameter in the graph, keyed by `(op, param)`. +/// +/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls +/// from — so an operation is persisted by virtue of being in the chain, with +/// nothing to register and nothing to forget. +fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> { + let mut out = BTreeMap::new(); + for cap in graph.capabilities() { + for p in &cap.params { + if p.is_modified() { + out.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value); + } + } + } + out +} + +/// Find the `'static` ids matching these names, or `None` if this build has +/// no such parameter. +/// +/// Looking them up in the descriptors rather than leaking the file's strings +/// is what bounds memory: an unrecognised name never becomes a `'static`. +fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> { + let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?; + let p = cap.params.iter().find(|p| p.id.0 == param)?; + Some((cap.id, p.id)) +} + +impl Sidecar { + pub fn new() -> Self { + Self::default() + } + + /// The version marked default, or the first if none is. + pub fn default_version(&self) -> Option<&Version> { + self.versions + .values() + .find(|v| v.is_default) + .or_else(|| self.versions.values().next()) + } + + /// 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"); + 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"); + } + 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); + } + for ((op, param), value) in &v.params { + let _ = writeln!(out, "{op}.{param} = {}", format_value(*value)); + } + for (k, raw) in &v.unknown { + let _ = writeln!(out, "{k} = {raw}"); + } + } + 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 { + 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::().ok()) + .ok_or(ParseError::NotASidecar)?; + if format > FORMAT_VERSION { + return Err(ParseError::UnsupportedVersion(format)); + } + + let mut sidecar = Sidecar::new(); + let mut current: Option = None; + + 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(']')) { + if let Some(v) = current.take() { + sidecar.put(v); + } + current = Some(Version { + uuid: uuid.trim().to_string(), + ..Version::default() + }); + 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()); + + let Some(version) = current.as_mut() else { + sidecar.unknown_blocks.push(line.to_string()); + continue; + }; + + match key { + "name" => version.name = value.to_string(), + "default" => version.is_default = value != "0", + "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::().unwrap_or(0).min(MAX_RATING) + } + "flag" => version.flag = value.parse::().unwrap_or(0).min(MAX_FLAG), + _ => 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::() { + 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()); + } + }, + } + } + if let Some(v) = current.take() { + sidecar.put(v); + } + Ok(sidecar) + } +} + +/// 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::{colour, exposure, 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) + } + + #[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 == colour::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); + + 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); + + 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); + + 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 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); + 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); + } + + #[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}" + ); + } + + #[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); + 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); + + 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); + 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("").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(colour::SATURATION_ID, colour::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); + assert_eq!( + g.param(colour::SATURATION_ID, colour::SATURATION), + Some(-100.0) + ); + assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); + } + + #[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); + 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); + 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); + 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); + 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_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); + 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"); + } +} +