M0.6: catalog v0 (ops with inverses, albums/folders, ratings/flags/labels, search tokens, filter/sort, snapshot + op-log replay)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 11:23:43 -04:00
parent 90c910a5f6
commit 76b2f82b7a
5 changed files with 904 additions and 0 deletions

18
crates/catalog/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "lightcraft-catalog"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft library: photos, albums, ratings/flags, search/filter/sort, op-log persistence (L3)"
[lints]
workspace = true
[dependencies]
lightcraft-develop = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

354
crates/catalog/src/lib.rs Normal file
View File

@ -0,0 +1,354 @@
//! The LightCraft library (catalog).
//!
//! State changes only through [`Op`]s. [`Catalog::apply`] returns the inverse op, which gives:
//! - **persistence**: ops are appended to a log (JSON lines) and replayed on load after the last
//! snapshot — crash-safe and diff-friendly;
//! - **undo/redo**: the engine keeps inverse ops;
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
#![forbid(unsafe_code)]
pub mod model;
pub mod query;
use std::collections::BTreeMap;
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
pub use model::*;
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum CatalogError {
#[error("no such photo {0:?}")]
NoPhoto(PhotoId),
#[error("no such album {0:?}")]
NoAlbum(AlbumId),
#[error("invalid: {0}")]
Invalid(String),
#[error("corrupt catalog data: {0}")]
Corrupt(String),
}
pub type Result<T> = std::result::Result<T, CatalogError>;
/// Every catalog mutation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum Op {
AddPhoto {
photo: Box<Photo>,
},
RemovePhoto {
id: PhotoId,
},
SetRating {
id: PhotoId,
rating: u8,
},
SetFlag {
id: PhotoId,
flag: Flag,
},
SetLabel {
id: PhotoId,
label: Option<ColorLabel>,
},
SetDevelop {
id: PhotoId,
settings: Arc<DevelopSettings>,
label: String,
edited: Option<String>,
},
SetMeta {
id: PhotoId,
meta: Box<Meta>,
},
SetDeleted {
id: PhotoId,
deleted: bool,
},
SetVersions {
id: PhotoId,
versions: Vec<Version>,
},
SetHistory {
id: PhotoId,
history: Vec<HistoryStep>,
},
AddAlbum {
album: Album,
},
RemoveAlbum {
id: AlbumId,
},
RenameAlbum {
id: AlbumId,
name: String,
},
MoveAlbum {
id: AlbumId,
parent: Option<AlbumId>,
},
SetAlbumPhotos {
id: AlbumId,
photos: Vec<PhotoId>,
},
SetAlbumCover {
id: AlbumId,
cover: Option<PhotoId>,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
},
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Catalog {
photos: BTreeMap<PhotoId, Arc<Photo>>,
albums: BTreeMap<AlbumId, Album>,
next_photo: u64,
next_album: u64,
/// Increments on every applied op.
#[serde(skip)]
pub revision: u64,
}
impl Catalog {
pub fn new() -> Catalog {
Catalog { next_photo: 1, next_album: 1, ..Default::default() }
}
// ---- ids
pub fn alloc_photo_id(&mut self) -> PhotoId {
let id = PhotoId(self.next_photo.max(1));
self.next_photo = id.0 + 1;
id
}
pub fn alloc_album_id(&mut self) -> AlbumId {
let id = AlbumId(self.next_album.max(1));
self.next_album = id.0 + 1;
id
}
// ---- reads
pub fn photo(&self, id: PhotoId) -> Option<&Arc<Photo>> {
self.photos.get(&id)
}
pub fn photos(&self) -> impl Iterator<Item = &Arc<Photo>> {
self.photos.values()
}
pub fn len(&self) -> usize {
self.photos.len()
}
pub fn is_empty(&self) -> bool {
self.photos.is_empty()
}
pub fn album(&self, id: AlbumId) -> Option<&Album> {
self.albums.get(&id)
}
pub fn albums(&self) -> impl Iterator<Item = &Album> {
self.albums.values()
}
pub fn albums_of(&self, id: PhotoId) -> Vec<AlbumId> {
self.albums.values().filter(|a| a.photos.contains(&id)).map(|a| a.id).collect()
}
// ---- writes
fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> {
self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id))
}
fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> {
self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))
}
/// Apply an op; returns its inverse. On error nothing changes.
pub fn apply(&mut self, op: Op) -> Result<Op> {
let inv = self.apply_inner(op)?;
self.revision += 1;
Ok(inv)
}
fn apply_inner(&mut self, op: Op) -> Result<Op> {
Ok(match op {
Op::AddPhoto { photo } => {
if self.photos.contains_key(&photo.id) {
return Err(CatalogError::Invalid(format!("photo {:?} exists", photo.id)));
}
let id = photo.id;
self.next_photo = self.next_photo.max(id.0 + 1);
self.photos.insert(id, Arc::new(*photo));
Op::RemovePhoto { id }
}
Op::RemovePhoto { id } => {
let p = self.photos.remove(&id).ok_or(CatalogError::NoPhoto(id))?;
// album membership is restored by the batch the engine builds (see `delete_permanently`)
Op::AddPhoto { photo: Box::new((*p).clone()) }
}
Op::SetRating { id, rating } => {
if rating > 5 {
return Err(CatalogError::Invalid("rating must be 0..=5".into()));
}
let p = self.photo_mut(id)?;
let old = std::mem::replace(&mut p.rating, rating);
Op::SetRating { id, rating: old }
}
Op::SetFlag { id, flag } => {
let p = self.photo_mut(id)?;
Op::SetFlag { id, flag: std::mem::replace(&mut p.flag, flag) }
}
Op::SetLabel { id, label } => {
let p = self.photo_mut(id)?;
Op::SetLabel { id, label: std::mem::replace(&mut p.label, label) }
}
Op::SetDevelop { id, settings, label, edited } => {
let p = self.photo_mut(id)?;
let old = std::mem::replace(&mut p.develop, settings);
let old_edit = std::mem::replace(&mut p.edited, edited);
Op::SetDevelop { id, settings: old, label, edited: old_edit }
}
Op::SetMeta { id, meta } => {
let p = self.photo_mut(id)?;
Op::SetMeta { id, meta: Box::new(std::mem::replace(&mut p.meta, *meta)) }
}
Op::SetDeleted { id, deleted } => {
let p = self.photo_mut(id)?;
Op::SetDeleted { id, deleted: std::mem::replace(&mut p.deleted, deleted) }
}
Op::SetVersions { id, versions } => {
let p = self.photo_mut(id)?;
Op::SetVersions { id, versions: std::mem::replace(&mut p.versions, versions) }
}
Op::SetHistory { id, history } => {
let p = self.photo_mut(id)?;
Op::SetHistory { id, history: std::mem::replace(&mut p.history, history) }
}
Op::AddAlbum { album } => {
if self.albums.contains_key(&album.id) {
return Err(CatalogError::Invalid(format!("album {:?} exists", album.id)));
}
if let Some(parent) = album.parent
&& !self.albums.get(&parent).is_some_and(|a| a.folder)
{
return Err(CatalogError::Invalid("parent must be an existing folder".into()));
}
let id = album.id;
self.next_album = self.next_album.max(id.0 + 1);
self.albums.insert(id, album);
Op::RemoveAlbum { id }
}
Op::RemoveAlbum { id } => {
if self.albums.values().any(|a| a.parent == Some(id)) {
return Err(CatalogError::Invalid("folder is not empty".into()));
}
let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?;
Op::AddAlbum { album: a }
}
Op::RenameAlbum { id, name } => {
let a = self.album_mut(id)?;
Op::RenameAlbum { id, name: std::mem::replace(&mut a.name, name) }
}
Op::MoveAlbum { id, parent } => {
if let Some(p) = parent {
if p == id || !self.albums.get(&p).is_some_and(|a| a.folder) {
return Err(CatalogError::Invalid("parent must be another folder".into()));
}
// no cycles
let mut cur = Some(p);
while let Some(c) = cur {
if c == id {
return Err(CatalogError::Invalid("cannot move a folder into itself".into()));
}
cur = self.albums.get(&c).and_then(|a| a.parent);
}
}
let a = self.album_mut(id)?;
Op::MoveAlbum { id, parent: std::mem::replace(&mut a.parent, parent) }
}
Op::SetAlbumPhotos { id, photos } => {
let a = self.album_mut(id)?;
if a.folder && !photos.is_empty() {
return Err(CatalogError::Invalid("folders can't hold photos".into()));
}
Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) }
}
Op::SetAlbumCover { id, cover } => {
let a = self.album_mut(id)?;
Op::SetAlbumCover { id, cover: std::mem::replace(&mut a.cover, cover) }
}
Op::Batch { ops } => {
let mut inverses = Vec::with_capacity(ops.len());
for op in ops {
match self.apply_inner(op) {
Ok(inv) => inverses.push(inv),
Err(e) => {
// roll back what was applied
for inv in inverses.into_iter().rev() {
let _ = self.apply_inner(inv);
}
return Err(e);
}
}
}
inverses.reverse();
Op::Batch { ops: inverses }
}
})
}
/// Ops that remove a photo permanently including album memberships (one undoable batch).
pub fn delete_permanently_ops(&self, id: PhotoId) -> Op {
let mut ops: Vec<Op> = self
.albums
.values()
.filter(|a| a.photos.contains(&id))
.map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| *p != id).collect() })
.collect();
ops.push(Op::RemovePhoto { id });
Op::Batch { ops }
}
// ---- persistence
/// Full snapshot as JSON.
pub fn to_snapshot(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn from_snapshot(s: &str) -> Result<Catalog> {
serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))
}
/// Replay an op log (JSON lines) on top of `self`. A torn final line (crash mid-write) is ignored.
pub fn replay(&mut self, log: &str) -> Result<usize> {
let lines: Vec<&str> = log.lines().filter(|l| !l.trim().is_empty()).collect();
let mut n = 0;
for (i, line) in lines.iter().enumerate() {
match serde_json::from_str::<Op>(line) {
Ok(op) => {
self.apply(op)?;
n += 1;
}
Err(e) if i + 1 == lines.len() => {
let _ = e;
break;
}
Err(e) => return Err(CatalogError::Corrupt(format!("log line {}: {e}", i + 1))),
}
}
Ok(n)
}
pub fn op_to_log_line(op: &Op) -> String {
let mut s = serde_json::to_string(op).unwrap_or_default();
s.push('\n');
s
}
}
#[cfg(test)]
mod tests;

193
crates/catalog/src/model.rs Normal file
View File

@ -0,0 +1,193 @@
//! Catalog records.
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PhotoId(pub u64);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AlbumId(pub u64);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MediaKind {
#[default]
Image,
Raw,
Video,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Flag {
#[default]
None,
Pick,
Reject,
}
impl Flag {
pub fn parse(s: &str) -> Option<Flag> {
match s.to_ascii_lowercase().as_str() {
"pick" | "picked" | "flagged" => Some(Flag::Pick),
"reject" | "rejected" => Some(Flag::Reject),
"none" | "unflagged" | "" => Some(Flag::None),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ColorLabel {
Red,
Yellow,
Green,
Blue,
Purple,
}
impl ColorLabel {
pub const ALL: [ColorLabel; 5] = [ColorLabel::Red, ColorLabel::Yellow, ColorLabel::Green, ColorLabel::Blue, ColorLabel::Purple];
pub fn parse(s: &str) -> Option<ColorLabel> {
ColorLabel::ALL.into_iter().find(|c| format!("{c:?}").eq_ignore_ascii_case(s))
}
}
/// Where the pixels come from.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Source {
/// A file on disk (native) or in the browser's storage (web).
File { path: String },
/// A procedurally generated demo scene (by `lightcraft-scenes` id).
Demo { scene: u32 },
}
/// Descriptive + capture metadata.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Meta {
pub camera: String,
pub lens: String,
pub focal_mm: Option<f32>,
pub aperture: Option<f32>,
pub shutter: String,
pub iso: Option<u32>,
pub location: String,
pub gps: Option<(f64, f64)>,
pub title: String,
pub caption: String,
pub copyright: String,
pub creator: String,
pub keywords: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Version {
pub name: String,
pub created: String,
pub settings: Arc<DevelopSettings>,
#[serde(default)]
pub auto: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HistoryStep {
pub label: String,
pub settings: Arc<DevelopSettings>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Photo {
pub id: PhotoId,
pub source: Source,
pub file_name: String,
#[serde(default)]
pub kind: MediaKind,
pub format: String,
pub width: u32,
pub height: u32,
#[serde(default)]
pub file_size: u64,
/// ISO 8601 local capture time.
#[serde(default)]
pub captured: Option<String>,
pub imported: String,
#[serde(default)]
pub meta: Meta,
#[serde(default)]
pub rating: u8,
#[serde(default)]
pub flag: Flag,
#[serde(default)]
pub label: Option<ColorLabel>,
pub develop: Arc<DevelopSettings>,
#[serde(default)]
pub edited: Option<String>,
#[serde(default)]
pub versions: Vec<Version>,
#[serde(default)]
pub history: Vec<HistoryStep>,
/// In "Recently Deleted".
#[serde(default)]
pub deleted: bool,
/// Video duration in seconds.
#[serde(default)]
pub duration: Option<f64>,
}
impl Photo {
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
id,
source,
file_name: file_name.to_string(),
kind: MediaKind::Image,
format: format.to_string(),
width,
height,
file_size: 0,
captured: None,
imported: imported.to_string(),
meta: Meta::default(),
rating: 0,
flag: Flag::None,
label: None,
develop: Arc::new(DevelopSettings::default()),
edited: None,
versions: Vec::new(),
history: Vec::new(),
deleted: false,
duration: None,
}
}
pub fn is_edited(&self) -> bool {
!self.develop.is_unedited()
}
/// Capture time if known, else import time (sort key).
pub fn date(&self) -> &str {
self.captured.as_deref().unwrap_or(&self.imported)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Album {
pub id: AlbumId,
pub name: String,
/// Containing folder (an album with `folder = true`).
#[serde(default)]
pub parent: Option<AlbumId>,
/// Folders hold albums, not photos.
#[serde(default)]
pub folder: bool,
#[serde(default)]
pub photos: Vec<PhotoId>,
#[serde(default)]
pub cover: Option<PhotoId>,
}

196
crates/catalog/src/query.rs Normal file
View File

@ -0,0 +1,196 @@
//! Filtering, search and sorting.
use serde::{Deserialize, Serialize};
use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RatingOp {
#[default]
AtLeast,
Exactly,
AtMost,
}
/// What the grid shows. Empty/None fields don't filter.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Filter {
/// Free-text search: every token must match filename, title, caption, keywords, camera, lens,
/// location or format. Tokens like `rating:3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded.
pub text: String,
pub rating: u8,
pub rating_op: RatingOp,
pub flag: Option<Flag>,
pub label: Option<ColorLabel>,
pub kind: Option<MediaKind>,
pub edited: Option<bool>,
pub album: Option<AlbumId>,
/// Show "Recently Deleted" instead of the library.
pub deleted: bool,
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
pub date: Option<String>,
pub keyword: Option<String>,
pub camera: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SortKey {
#[default]
CaptureDate,
ImportDate,
EditDate,
FileName,
Rating,
FileSize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Sort {
pub key: SortKey,
pub ascending: bool,
}
impl Default for Sort {
fn default() -> Self {
Sort { key: SortKey::CaptureDate, ascending: false }
}
}
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
if let Some((field, val)) = t.split_once(':') {
let num = |s: &str| -> Option<(char, f64)> {
let (op, rest) = match s.chars().next()? {
c @ ('>' | '<' | '=') => (c, &s[1..]),
_ => ('=', s),
};
rest.parse().ok().map(|v| (op, v))
};
let cmp = |x: f64, s: &str| match num(s) {
Some(('>', v)) => x > v,
Some(('<', v)) => x < v,
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
"f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
"focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| k.to_lowercase() == val),
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.date().starts_with(val),
"name" | "file" => p.file_name.to_lowercase().contains(val),
_ => false,
};
}
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
}
impl Filter {
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if p.deleted != self.deleted {
return false;
}
if self.rating > 0 {
let ok = match self.rating_op {
RatingOp::AtLeast => p.rating >= self.rating,
RatingOp::Exactly => p.rating == self.rating,
RatingOp::AtMost => p.rating <= self.rating,
};
if !ok {
return false;
}
}
if self.flag.is_some_and(|f| f != p.flag) || self.label.is_some_and(|l| Some(l) != p.label) || self.kind.is_some_and(|k| k != p.kind) {
return false;
}
if self.edited.is_some_and(|e| e != p.is_edited()) {
return false;
}
if let Some(a) = self.album
&& !cat.album(a).is_some_and(|al| al.photos.contains(&p.id))
{
return false;
}
if let Some(d) = &self.date
&& !p.date().starts_with(d.as_str())
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(k))
{
return false;
}
if let Some(c) = &self.camera
&& !p.meta.camera.eq_ignore_ascii_case(c)
{
return false;
}
self.text.split_whitespace().all(|tok| token_matches(p, tok))
}
}
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.date().cmp(b.date()),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
}
.then(a.id.cmp(&b.id));
if sort.ascending { o } else { o.reverse() }
});
v.into_iter().map(|p| p.id).collect()
}
/// Year → month → count tree for the "By Date" section.
pub fn date_groups(&self) -> Vec<DateGroup> {
let mut map: std::collections::BTreeMap<String, std::collections::BTreeMap<String, usize>> = Default::default();
for p in self.photos().filter(|p| !p.deleted) {
let d = p.date();
if d.len() >= 7 {
*map.entry(d[..4].to_string()).or_default().entry(d[..7].to_string()).or_default() += 1;
}
}
map.into_iter()
.rev()
.map(|(year, months)| DateGroup { count: months.values().sum(), months: months.into_iter().rev().map(|(m, c)| (m, c)).collect(), year })
.collect()
}
/// All keywords with usage counts, sorted by name.
pub fn keywords(&self) -> Vec<(String, usize)> {
let mut m: std::collections::BTreeMap<String, usize> = Default::default();
for p in self.photos().filter(|p| !p.deleted) {
for k in &p.meta.keywords {
*m.entry(k.clone()).or_default() += 1;
}
}
m.into_iter().collect()
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DateGroup {
pub year: String,
pub count: usize,
pub months: Vec<(String, usize)>,
}

143
crates/catalog/src/tests.rs Normal file
View File

@ -0,0 +1,143 @@
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use super::*;
fn photo(c: &mut Catalog, name: &str, date: &str) -> PhotoId {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
p.captured = Some(date.to_string());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
}
#[test]
fn apply_and_inverse_roundtrip() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01T10:00:00");
let before = c.clone();
let ops = vec![
Op::SetRating { id: a, rating: 4 },
Op::SetFlag { id: a, flag: Flag::Pick },
Op::SetLabel { id: a, label: Some(ColorLabel::Red) },
Op::SetDevelop {
id: a,
settings: Arc::new(DevelopSettings { treatment: lightcraft_develop::Treatment::Bw, ..Default::default() }),
label: "B&W".into(),
edited: Some("x".into()),
},
];
let mut invs = Vec::new();
for op in ops {
invs.push(c.apply(op).unwrap());
}
assert_eq!(c.photo(a).unwrap().rating, 4);
for inv in invs.into_iter().rev() {
c.apply(inv).unwrap();
}
assert_eq!(c.photos().collect::<Vec<_>>(), before.photos().collect::<Vec<_>>());
}
#[test]
fn invalid_ops_change_nothing() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01");
let snap = c.to_snapshot();
assert!(c.apply(Op::SetRating { id: a, rating: 9 }).is_err());
assert!(c.apply(Op::SetRating { id: PhotoId(999), rating: 1 }).is_err());
// batch with a failing op rolls back
let r = c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }, Op::SetFlag { id: PhotoId(77), flag: Flag::Pick }] });
assert!(r.is_err());
assert_eq!(c.to_snapshot(), snap);
}
#[test]
fn albums_and_folders() {
let mut c = Catalog::new();
let a = photo(&mut c, "a.jpg", "2026-04-01");
let f = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None } }).unwrap();
let al = c.alloc_album_id();
c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None } }).unwrap();
assert_eq!(c.albums_of(a), vec![al]);
assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder");
assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos");
assert!(c.apply(Op::MoveAlbum { id: f, parent: Some(f) }).is_err());
let del = c.delete_permanently_ops(a);
let inv = c.apply(del).unwrap();
assert!(c.photo(a).is_none() && c.album(al).unwrap().photos.is_empty());
c.apply(inv).unwrap();
assert!(c.photo(a).is_some() && c.album(al).unwrap().photos == vec![a]);
}
#[test]
fn filter_search_sort() {
let mut c = Catalog::new();
let a = photo(&mut c, "beach.jpg", "2026-05-01T10:00:00");
let b = photo(&mut c, "alps.jpg", "2026-04-01T10:00:00");
let d = photo(&mut c, "city.jpg", "2025-12-24T10:00:00");
c.apply(Op::SetRating { id: b, rating: 5 }).unwrap();
c.apply(Op::SetFlag { id: d, flag: Flag::Reject }).unwrap();
let mut m = c.photo(a).unwrap().meta.clone();
m.keywords = vec!["ocean".into(), "summer".into()];
m.iso = Some(1600);
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
let all = c.query(&Filter::default(), &Sort::default());
assert_eq!(all, vec![a, b, d], "newest first");
let asc = c.query(&Filter::default(), &Sort { key: SortKey::FileName, ascending: true });
assert_eq!(asc, vec![b, a, d]);
assert_eq!(c.query(&Filter { rating: 4, ..Default::default() }, &Sort::default()), vec![b]);
assert_eq!(c.query(&Filter { flag: Some(Flag::Reject), ..Default::default() }, &Sort::default()), vec![d]);
assert_eq!(c.query(&Filter { text: "summer".into(), ..Default::default() }, &Sort::default()), vec![a]);
assert_eq!(c.query(&Filter { text: "iso:>800".into(), ..Default::default() }, &Sort::default()), vec![a]);
assert_eq!(c.query(&Filter { text: "date:2025".into(), ..Default::default() }, &Sort::default()), vec![d]);
assert_eq!(c.query(&Filter { date: Some("2026-04".into()), ..Default::default() }, &Sort::default()), vec![b]);
c.apply(Op::SetDeleted { id: d, deleted: true }).unwrap();
assert_eq!(c.query(&Filter::default(), &Sort::default()).len(), 2);
assert_eq!(c.query(&Filter { deleted: true, ..Default::default() }, &Sort::default()), vec![d]);
let g = c.date_groups();
assert_eq!(g[0].year, "2026");
assert_eq!(g[0].count, 2);
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
}
#[test]
fn snapshot_and_log_replay() {
let mut c = Catalog::new();
let snap0 = c.to_snapshot();
let mut log = String::new();
let a = c.alloc_photo_id();
let ops = vec![
Op::AddPhoto { photo: Box::new(Photo::new(a, Source::File { path: "/x/a.jpg".into() }, "a.jpg", "JPEG", 10, 10, "t")) },
Op::SetRating { id: a, rating: 3 },
Op::SetFlag { id: a, flag: Flag::Pick },
];
for op in ops {
log.push_str(&Catalog::op_to_log_line(&op));
c.apply(op).unwrap();
}
let mut r = Catalog::from_snapshot(&snap0).unwrap();
// torn last line is tolerated
let torn = format!("{log}{{\"op\":\"setRat");
assert_eq!(r.replay(&torn).unwrap(), 3);
assert_eq!(r.to_snapshot(), c.to_snapshot());
assert!(Catalog::from_snapshot("{nope").is_err());
}
proptest::proptest! {
#[test]
fn random_ops_undo_to_start(ratings in proptest::collection::vec((0usize..3, 0u8..6, 0u8..3), 1..40)) {
let mut c = Catalog::new();
let ids: Vec<PhotoId> = (0..3).map(|i| photo(&mut c, &format!("p{i}.jpg"), "2026-01-01")).collect();
let start = c.to_snapshot();
let mut invs = Vec::new();
for (i, r, f) in ratings {
let op = if f == 0 { Op::SetRating { id: ids[i], rating: r } } else { Op::SetFlag { id: ids[i], flag: [Flag::None, Flag::Pick, Flag::Reject][f as usize] } };
if let Ok(inv) = c.apply(op) { invs.push(inv); }
}
for inv in invs.into_iter().rev() { c.apply(inv).unwrap(); }
proptest::prop_assert_eq!(c.to_snapshot(), start);
}
}