Skip to main content

falcon_library/
undo.rs

1//! Undo transactions and batched writes.
2//!
3//! Every library write goes through a few functions (`register_item`,
4//! `forget_item`, `save_metadata`, `save_tags`, `save_filters`). While a
5//! transaction is open they record what they replace: the first pre-edit
6//! copy of each item, the ids of items that did not exist before, and the
7//! metadata, tags and saved filters as last saved. [`Library::revert`] puts
8//! that state back and returns the inverse transaction, for redo.
9//!
10//! In [`WriteMode::Batched`] the same functions only mark what is dirty, and
11//! [`Library::flush`] writes each dirty file once. The bytes are the same as
12//! [`WriteMode::Immediate`] would have left: every writer serialises the
13//! whole in-memory value, so the last write wins either way.
14
15use std::collections::BTreeSet;
16
17use crate::error::Result;
18use crate::library::{Library, write_json};
19use crate::model::{Item, LibraryMetadata, SavedFilter, Tags};
20
21/// A group of edits that can be reverted together.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Transaction {
24    /// What the edit was, for "Undid: …" messages.
25    pub label: String,
26    /// Pre-edit copies of changed items, the first one per id.
27    items: Vec<Item>,
28    /// Items registered during the transaction that did not exist before.
29    created: Vec<String>,
30    metadata: Option<LibraryMetadata>,
31    tags: Option<Tags>,
32    saved_filters: Option<Vec<SavedFilter>>,
33    /// Set when items were deleted for good during the transaction: their
34    /// files are gone, so the edit can't be undone and an undo history from
35    /// before it no longer applies.
36    pub irreversible: bool,
37}
38
39impl Transaction {
40    fn new(label: String) -> Transaction {
41        Transaction { label, items: Vec::new(), created: Vec::new(), metadata: None, tags: None, saved_filters: None, irreversible: false }
42    }
43
44    /// True when nothing was recorded.
45    pub fn is_empty(&self) -> bool {
46        self.items.is_empty() && self.created.is_empty() && self.metadata.is_none() && self.tags.is_none() && self.saved_filters.is_none()
47    }
48
49    /// Ids of items the transaction changed or created.
50    pub fn item_ids(&self) -> impl Iterator<Item = &str> {
51        self.items.iter().map(|i| i.id.as_str()).chain(self.created.iter().map(String::as_str))
52    }
53
54    /// The item `id` as it was before the transaction changed it.
55    pub fn old_item(&self, id: &str) -> Option<&Item> {
56        self.items.iter().find(|i| i.id == id)
57    }
58}
59
60/// When library writes reach the disk.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum WriteMode {
63    /// Each edit writes its files at once (the default; the CLI and server
64    /// use it).
65    #[default]
66    Immediate,
67    /// Edits mark files dirty and [`Library::flush`] writes them.
68    Batched,
69}
70
71/// What a [`Library::flush`] wrote.
72#[derive(Debug, Clone, Default, PartialEq)]
73pub struct FlushReport {
74    /// Item `metadata.json` files written.
75    pub items: usize,
76    pub mtime: bool,
77    pub metadata: bool,
78    pub tags: bool,
79    pub saved_filters: bool,
80}
81
82impl FlushReport {
83    /// Whether `mtime.json` or `metadata.json` was written. Eagle backs
84    /// the metadata up after it rewrites `mtime.json`, so callers that keep
85    /// backups (the app) schedule one when this is true.
86    pub fn wants_backup(&self) -> bool {
87        self.mtime || self.metadata
88    }
89}
90
91/// Files waiting for a flush in batched mode.
92#[derive(Debug, Clone, Default)]
93pub(crate) struct Dirty {
94    pub(crate) items: BTreeSet<String>,
95    pub(crate) mtime: bool,
96    pub(crate) metadata: bool,
97    pub(crate) tags: bool,
98    pub(crate) saved_filters: bool,
99}
100
101impl Dirty {
102    fn any(&self) -> bool {
103        !self.items.is_empty() || self.mtime || self.metadata || self.tags || self.saved_filters
104    }
105}
106
107/// The library-wide values as last saved.
108#[derive(Debug, Clone)]
109pub(crate) struct Committed {
110    pub(crate) metadata: LibraryMetadata,
111    pub(crate) tags: Tags,
112    pub(crate) saved_filters: Vec<SavedFilter>,
113}
114
115impl Library {
116    /// Starts recording edits. An already open transaction is ended and
117    /// dropped first, so a transaction never spans more than one caller's
118    /// unit of work.
119    pub fn begin_transaction(&mut self, label: impl Into<String>) {
120        self.journal = Some(Transaction::new(label.into()));
121    }
122
123    /// Renames the open transaction (the app names it once it knows what
124    /// the frame did).
125    pub fn set_transaction_label(&mut self, label: impl Into<String>) {
126        if let Some(j) = &mut self.journal {
127            j.label = label.into();
128        }
129    }
130
131    /// Stops recording and returns what was recorded, or `None` when
132    /// nothing changed.
133    pub fn end_transaction(&mut self) -> Option<Transaction> {
134        self.journal.take().filter(|t| !t.is_empty() || t.irreversible)
135    }
136
137    /// Puts back the state `tx` recorded and returns the inverse
138    /// transaction (reverting that redoes `tx`). Items are saved through
139    /// [`Library::update_item`], so renamed files get their old names back
140    /// and `lastModified` moves forward, as with any edit (Eagle's own undo
141    /// also saves the item anew). Items `tx` created go to the trash; their
142    /// files stay. Metadata, tags and saved filters are restored and
143    /// written (metadata gets a fresh `modificationTime`). Items deleted for
144    /// good since are skipped. A transaction open around the call is set
145    /// aside meanwhile, so the revert is not recorded in it.
146    pub fn revert(&mut self, tx: Transaction) -> Result<Transaction> {
147        let outer = self.journal.take();
148        self.begin_transaction(tx.label.clone());
149        let result = self.apply(&tx);
150        let inverse = self.journal.take().unwrap_or_else(|| Transaction::new(tx.label.clone()));
151        self.journal = outer;
152        result.map(|()| inverse)
153    }
154
155    fn apply(&mut self, tx: &Transaction) -> Result<()> {
156        if let Some(m) = &tx.metadata {
157            self.metadata = m.clone();
158            self.save_metadata()?;
159        }
160        if let Some(t) = &tx.tags {
161            self.tags = t.clone();
162            self.save_tags()?;
163        }
164        if let Some(f) = &tx.saved_filters {
165            self.saved_filters = f.clone();
166            self.save_filters()?;
167        }
168        for old in &tx.items {
169            if self.item(&old.id).is_some() {
170                self.update_item(old.clone())?;
171            }
172        }
173        let created: Vec<String> = tx.created.iter().filter(|id| self.item(id).is_some_and(|i| !i.is_deleted)).cloned().collect();
174        self.set_trashed(&created, true)
175    }
176
177    /// Records `id` in an open transaction: its current copy the first time
178    /// it changes, or as created if it doesn't exist yet.
179    pub(crate) fn journal_item(&mut self, id: &str) {
180        let Some(j) = &self.journal else { return };
181        if j.items.iter().any(|i| i.id == id) || j.created.iter().any(|c| c == id) {
182            return;
183        }
184        let old = self.item(id).cloned();
185        let j = self.journal.as_mut().expect("checked above");
186        match old {
187            Some(item) => j.items.push(item),
188            None => j.created.push(id.to_string()),
189        }
190    }
191
192    /// Notes that `metadata` is being saved: the copy saved before goes into
193    /// an open transaction (once), and the current one becomes the saved one.
194    pub(crate) fn journal_metadata(&mut self) {
195        let prev = std::mem::replace(&mut self.committed.metadata, self.metadata.clone());
196        if let Some(j) = &mut self.journal {
197            j.metadata.get_or_insert(prev);
198        }
199    }
200
201    pub(crate) fn journal_tags(&mut self) {
202        let prev = std::mem::replace(&mut self.committed.tags, self.tags.clone());
203        if let Some(j) = &mut self.journal {
204            j.tags.get_or_insert(prev);
205        }
206    }
207
208    pub(crate) fn journal_saved_filters(&mut self) {
209        let prev = std::mem::replace(&mut self.committed.saved_filters, self.saved_filters.clone());
210        if let Some(j) = &mut self.journal {
211            j.saved_filters.get_or_insert(prev);
212        }
213    }
214
215    /// In batched mode, marks something dirty and returns true (the caller
216    /// skips its write); in immediate mode returns false.
217    pub(crate) fn batching(&mut self, mark: impl FnOnce(&mut Dirty)) -> bool {
218        match self.write_mode {
219            WriteMode::Immediate => false,
220            WriteMode::Batched => {
221                mark(&mut self.dirty);
222                true
223            }
224        }
225    }
226
227    /// The current write mode.
228    pub fn write_mode(&self) -> WriteMode {
229        self.write_mode
230    }
231
232    /// Switches between immediate and batched writes. Leaving batched mode
233    /// flushes what is pending.
234    pub fn set_write_mode(&mut self, mode: WriteMode) -> Result<()> {
235        if mode == WriteMode::Immediate && self.write_mode == WriteMode::Batched {
236            self.flush()?;
237        }
238        self.write_mode = mode;
239        Ok(())
240    }
241
242    /// True when batched writes are waiting for [`Library::flush`].
243    pub fn has_pending_writes(&self) -> bool {
244        self.dirty.any()
245    }
246
247    /// Writes everything batched mode held back: each dirty item once, then
248    /// `mtime.json`, `metadata.json`, `tags.json` and `saved-filters.json`
249    /// once each. It takes no backup; [`FlushReport::wants_backup`] tells
250    /// the caller when to (see [`Library::backup_metadata`]). On an error
251    /// the pending set is kept, so the next flush tries again.
252    pub fn flush(&mut self) -> Result<FlushReport> {
253        let mut report = FlushReport::default();
254        if !self.dirty.any() {
255            return Ok(report);
256        }
257        let root = self.root().to_path_buf();
258        for id in &self.dirty.items {
259            // An item deleted since it was marked has nothing left to write.
260            if let Some(item) = self.item(id) {
261                write_json(&item.info_dir(&root).join("metadata.json"), item)?;
262                report.items += 1;
263            }
264        }
265        if self.dirty.mtime {
266            write_json(&root.join("mtime.json"), &self.mtime)?;
267            report.mtime = true;
268        }
269        if self.dirty.metadata {
270            if let Err(e) = self.write_metadata_file() {
271                // A refused write (sync conflict, newer Eagle) is not
272                // retried: the caller reloads the library from disk.
273                if matches!(e, crate::Error::SyncConflict(_) | crate::Error::NewerLibrary { .. }) {
274                    self.dirty.metadata = false;
275                }
276                return Err(e);
277            }
278            report.metadata = true;
279        }
280        if self.dirty.tags {
281            write_json(&root.join("tags.json"), &self.tags)?;
282            report.tags = true;
283        }
284        if self.dirty.saved_filters {
285            write_json(&root.join("saved-filters.json"), &self.saved_filters)?;
286            report.saved_filters = true;
287        }
288        self.dirty = Dirty::default();
289        Ok(report)
290    }
291}
292
293/// A batched library flushes what is pending when dropped, so nothing is
294/// lost when the app closes it. Errors can't be reported here; call
295/// [`Library::flush`] first to see them. (A clone shares the pending set at
296/// the time of cloning, so drop clones of a batched library before the
297/// original makes further edits, or flush first.)
298impl Drop for Library {
299    fn drop(&mut self) {
300        if self.write_mode == WriteMode::Batched && self.dirty.any() {
301            let _ = self.flush();
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::import::ImportOptions;
310    use crate::smart;
311    use std::collections::BTreeMap;
312    use std::fs;
313    use std::path::{Path, PathBuf};
314
315    /// A new library with two imported PNGs, `a` and `b`.
316    fn setup(dir: &Path) -> (Library, String, String) {
317        let mut lib = Library::create(dir.join("Test.library")).unwrap();
318        let mut ids = Vec::new();
319        for (name, rgb) in [("a", [200, 30, 30]), ("b", [30, 30, 200])] {
320            let path = dir.join(format!("{name}.png"));
321            image::RgbImage::from_pixel(8, 6, image::Rgb(rgb)).save(&path).unwrap();
322            ids.push(lib.import_file(&path, &ImportOptions::default()).unwrap());
323        }
324        let b = ids.pop().unwrap();
325        (lib, ids.pop().unwrap(), b)
326    }
327
328    /// Runs `edit` in a transaction that must record something.
329    fn tx(lib: &mut Library, edit: impl FnOnce(&mut Library)) -> Transaction {
330        lib.begin_transaction("edit");
331        edit(lib);
332        lib.end_transaction().expect("something changed")
333    }
334
335    fn item(lib: &Library, id: &str) -> Item {
336        lib.item(id).unwrap().clone()
337    }
338
339    /// An item without `lastModified`, which every save bumps.
340    fn content(lib: &Library, id: &str) -> Item {
341        let mut i = item(lib, id);
342        i.last_modified = 0;
343        i
344    }
345
346    /// Every file under `root`, by relative path, ignoring metadata backups.
347    fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
348        let mut out = BTreeMap::new();
349        let mut stack = vec![root.to_path_buf()];
350        while let Some(d) = stack.pop() {
351            for e in fs::read_dir(&d).unwrap().flatten() {
352                let p = e.path();
353                if p.is_dir() {
354                    if p.file_name().is_some_and(|n| n != "backup") {
355                        stack.push(p);
356                    }
357                } else {
358                    out.insert(p.strip_prefix(root).unwrap().to_path_buf(), fs::read(&p).unwrap());
359                }
360            }
361        }
362        out
363    }
364
365    #[test]
366    fn rename_undo_and_redo_move_the_files() {
367        let tmp = tempfile::tempdir().unwrap();
368        let (mut lib, a, _) = setup(tmp.path());
369        let before = content(&lib, &a);
370        let t = tx(&mut lib, |lib| {
371            let mut i = item(lib, &a);
372            i.name = "renamed".into();
373            lib.update_item(i).unwrap();
374        });
375        let root = lib.root().to_path_buf();
376        assert!(root.join(format!("images/{a}.info/renamed.png")).is_file());
377        let redo = lib.revert(t).unwrap();
378        assert_eq!(content(&lib, &a), before);
379        assert!(root.join(format!("images/{a}.info/a.png")).is_file());
380        assert!(!root.join(format!("images/{a}.info/renamed_thumbnail.png")).exists());
381        assert!(!root.join(format!("images/{a}.info/renamed.png")).exists());
382        // Disk matches memory after an undo.
383        let on_disk = Library::open(&root).unwrap();
384        assert_eq!(on_disk.item(&a), lib.item(&a));
385        let undo = lib.revert(redo).unwrap();
386        assert_eq!(item(&lib, &a).name, "renamed");
387        assert!(root.join(format!("images/{a}.info/renamed.png")).is_file());
388        lib.revert(undo).unwrap();
389        assert_eq!(item(&lib, &a).name, "a");
390    }
391
392    #[test]
393    fn trash_and_restore_undo() {
394        let tmp = tempfile::tempdir().unwrap();
395        let (mut lib, a, b) = setup(tmp.path());
396        let before = (content(&lib, &a), content(&lib, &b));
397        let t = tx(&mut lib, |lib| lib.set_trashed(&[a.clone(), b.clone()], true).unwrap());
398        assert!(item(&lib, &a).is_deleted);
399        let redo = lib.revert(t).unwrap();
400        assert_eq!((content(&lib, &a), content(&lib, &b)), before);
401        assert!(!item(&lib, &a).extra.contains_key("deletedTime"));
402        lib.revert(redo).unwrap();
403        assert!(item(&lib, &a).is_deleted && item(&lib, &b).is_deleted);
404    }
405
406    #[test]
407    fn tag_edits_undo_items_and_tags_json() {
408        let tmp = tempfile::tempdir().unwrap();
409        let (mut lib, a, _) = setup(tmp.path());
410        let tags_before = lib.tags.clone();
411        let t = tx(&mut lib, |lib| {
412            let mut i = item(lib, &a);
413            i.tags.push("red".into());
414            lib.update_item(i).unwrap();
415            lib.record_tag_use(&["red".into()]).unwrap();
416            lib.set_tag_starred("red", true).unwrap();
417        });
418        let redo = lib.revert(t).unwrap();
419        assert!(item(&lib, &a).tags.is_empty());
420        assert_eq!(lib.tags, tags_before);
421        let on_disk = Library::open(lib.root()).unwrap();
422        assert_eq!(on_disk.tags, tags_before);
423        lib.revert(redo).unwrap();
424        assert_eq!(item(&lib, &a).tags, ["red"]);
425        assert_eq!(lib.tags.starred_tags, ["red"]);
426    }
427
428    #[test]
429    fn folder_edits_undo() {
430        let tmp = tempfile::tempdir().unwrap();
431        let (mut lib, a, b) = setup(tmp.path());
432        let empty = lib.metadata.folders.clone();
433        let mut f = String::new();
434        let t = tx(&mut lib, |lib| f = lib.create_folder(None, "One").unwrap());
435        lib.revert(t).unwrap();
436        assert_eq!(lib.metadata.folders, empty);
437
438        let f = lib.create_folder(None, "One").unwrap();
439        let g = lib.create_folder(None, "Two").unwrap();
440        let t = tx(&mut lib, |lib| lib.rename_folder(&f, "Uno").unwrap());
441        lib.revert(t).unwrap();
442        assert_eq!(lib.all_folders()[0].name, "One");
443
444        let tree = lib.metadata.folders.clone();
445        let t = tx(&mut lib, |lib| lib.move_folder(&g, Some(&f)).unwrap());
446        assert_eq!(lib.metadata.folders.len(), 1);
447        lib.revert(t).unwrap();
448        assert_eq!(lib.metadata.folders, tree);
449
450        // Items filed in a deleted folder come back to it, out of the trash.
451        for (id, folders) in [(&a, vec![f.clone()]), (&b, vec![f.clone(), g.clone()])] {
452            let mut i = item(&lib, id);
453            i.folders = folders;
454            lib.update_item(i).unwrap();
455        }
456        let before = (content(&lib, &a), content(&lib, &b));
457        let t = tx(&mut lib, |lib| lib.delete_folder(&f).unwrap());
458        assert!(item(&lib, &a).is_deleted);
459        assert_eq!(item(&lib, &b).folders, std::slice::from_ref(&g));
460        lib.revert(t).unwrap();
461        assert_eq!((content(&lib, &a), content(&lib, &b)), before);
462        assert_eq!(lib.metadata.folders, tree);
463        let on_disk = Library::open(lib.root()).unwrap();
464        assert_eq!(on_disk.metadata.folders, tree);
465    }
466
467    /// The folder inspector's name and description edits change only those
468    /// two keys of the folder in `metadata.json` (plus the file's own
469    /// `modificationTime`, which every save stamps), and one undo step
470    /// restores them.
471    #[test]
472    fn folder_name_and_description_round_trip() {
473        let tmp = tempfile::tempdir().unwrap();
474        let (mut lib, _, _) = setup(tmp.path());
475        let f = lib.create_folder(None, "One").unwrap();
476        let meta = lib.root().join("metadata.json");
477        // The file without its top-level save time.
478        let read = || {
479            let mut v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&meta).unwrap()).unwrap();
480            v.as_object_mut().unwrap().shift_remove("modificationTime").expect("save time");
481            v.to_string()
482        };
483        let before = read();
484        assert_eq!(before.matches(r#""name":"One","description":"""#).count(), 1, "{before}");
485        let t = tx(&mut lib, |lib| {
486            lib.rename_folder(&f, "Uno").unwrap();
487            lib.set_folder_description(&f, "Line one\nline \"two\"").unwrap();
488        });
489        let after = read();
490        assert_eq!(after, before.replace(r#""name":"One","description":"""#, r#""name":"Uno","description":"Line one\nline \"two\"""#));
491        let reopened = Library::open(lib.root()).unwrap();
492        let folder = &reopened.all_folders()[0];
493        assert_eq!((folder.name.as_str(), folder.description.as_str()), ("Uno", "Line one\nline \"two\""));
494        let redo = lib.revert(t).unwrap();
495        assert_eq!(read(), before);
496        lib.revert(redo).unwrap();
497        assert_eq!(read(), after);
498        assert!(lib.set_folder_description("missing", "x").is_err());
499    }
500
501    #[test]
502    fn tag_group_edits_undo() {
503        let tmp = tempfile::tempdir().unwrap();
504        let (mut lib, _, _) = setup(tmp.path());
505        let t = tx(&mut lib, |lib| {
506            lib.create_tag_group("Colours", &["red".into()], None).unwrap();
507        });
508        lib.revert(t).unwrap();
509        assert!(lib.metadata.tags_groups.is_empty());
510        let id = lib.create_tag_group("Colours", &["red".into()], None).unwrap();
511        let before = lib.metadata.tags_groups.clone();
512        let t = tx(&mut lib, |lib| {
513            lib.rename_tag_group(&id, "Hues").unwrap();
514            lib.add_tags_to_group(&id, &["blue".into()]).unwrap();
515        });
516        let redo = lib.revert(t).unwrap();
517        assert_eq!(lib.metadata.tags_groups, before);
518        lib.revert(redo).unwrap();
519        assert_eq!(lib.metadata.tags_groups[0].name, "Hues");
520        assert_eq!(lib.metadata.tags_groups[0].tags, ["red", "blue"]);
521    }
522
523    #[test]
524    fn smart_folder_and_saved_filter_edits_undo() {
525        let tmp = tempfile::tempdir().unwrap();
526        let (mut lib, _, _) = setup(tmp.path());
527        let rule = serde_json::json!({"conditions": [{"match": "AND", "rules": [{"property": "name", "method": "contain", "value": "a"}]}]});
528        let conditions = rule["conditions"].as_array().unwrap().clone();
529        let t = tx(&mut lib, |lib| {
530            lib.create_smart_folder("Smart", conditions.clone()).unwrap();
531        });
532        lib.revert(t).unwrap();
533        assert!(lib.smart_folders().is_empty());
534        let id = lib.create_smart_folder("Smart", conditions.clone()).unwrap();
535        let t = tx(&mut lib, |lib| lib.update_smart_folder(&id, "Clever", Vec::new()).unwrap());
536        lib.revert(t).unwrap();
537        let sf: Vec<smart::SmartFolder> = lib.smart_folders();
538        assert_eq!(sf[0].name, "Smart");
539        let t = tx(&mut lib, |lib| lib.delete_smart_folder(&id).unwrap());
540        lib.revert(t).unwrap();
541        assert_eq!(lib.smart_folders().len(), 1);
542
543        let t = tx(&mut lib, |lib| {
544            lib.create_saved_filter("Saved", rule.clone()).unwrap();
545        });
546        lib.revert(t).unwrap();
547        assert!(lib.saved_filters.is_empty());
548        assert_eq!(fs::read(lib.root().join("saved-filters.json")).unwrap(), b"[]");
549    }
550
551    #[test]
552    fn imports_undo_to_the_trash() {
553        let tmp = tempfile::tempdir().unwrap();
554        let (mut lib, _, _) = setup(tmp.path());
555        let path = tmp.path().join("c.png");
556        image::RgbImage::from_pixel(4, 4, image::Rgb([0, 200, 0])).save(&path).unwrap();
557        let mut c = String::new();
558        let t = tx(&mut lib, |lib| c = lib.import_file(&path, &ImportOptions::default()).unwrap());
559        assert_eq!(t.item_ids().collect::<Vec<_>>(), [c.as_str()]);
560        let redo = lib.revert(t).unwrap();
561        assert!(item(&lib, &c).is_deleted);
562        assert!(item(&lib, &c).original_path(lib.root()).is_file());
563        lib.revert(redo).unwrap();
564        assert!(!item(&lib, &c).is_deleted);
565    }
566
567    #[test]
568    fn permanent_deletes_are_irreversible() {
569        let tmp = tempfile::tempdir().unwrap();
570        let (mut lib, a, _) = setup(tmp.path());
571        lib.begin_transaction("Delete");
572        lib.delete_permanently(&[a]).unwrap();
573        assert!(lib.end_transaction().unwrap().irreversible);
574        lib.begin_transaction("Nothing");
575        assert_eq!(lib.end_transaction(), None);
576    }
577
578    #[test]
579    fn revert_inside_an_open_transaction_is_not_recorded_in_it() {
580        let tmp = tempfile::tempdir().unwrap();
581        let (mut lib, a, _) = setup(tmp.path());
582        let t = tx(&mut lib, |lib| lib.set_trashed(std::slice::from_ref(&a), true).unwrap());
583        lib.begin_transaction("frame");
584        lib.revert(t).unwrap();
585        assert_eq!(lib.end_transaction(), None);
586    }
587
588    /// The same edits, for the batching tests.
589    fn edits(lib: &mut Library, a: &str, b: &str) {
590        let f = lib.create_folder(None, "F").unwrap();
591        lib.rename_folder(&f, "G").unwrap();
592        for id in [a, b] {
593            let mut i = item(lib, id);
594            i.tags.push("x".into());
595            i.folders.push(f.clone());
596            lib.update_item(i).unwrap();
597        }
598        let mut i = item(lib, a);
599        i.name = "first".into();
600        lib.update_item(i).unwrap();
601        lib.record_tag_use(&["x".into()]).unwrap();
602        lib.create_tag_group("G", &["x".into()], None).unwrap();
603        lib.create_saved_filter("S", serde_json::json!({})).unwrap();
604        lib.set_trashed(&[b.to_string()], true).unwrap();
605    }
606
607    /// Normalises the clock-dependent values so two runs compare equal.
608    fn normalise(lib: &mut Library) {
609        lib.metadata.modification_time = 0;
610        for i in lib.items_mut() {
611            i.last_modified = 0;
612            i.extra.remove("deletedTime");
613        }
614        for e in &mut lib.mtime.entries {
615            e.1 = 0;
616        }
617    }
618
619    #[test]
620    fn batched_flush_writes_what_immediate_writes() {
621        let tmp = tempfile::tempdir().unwrap();
622        let (one, two) = (tmp.path().join("one"), tmp.path().join("two"));
623        fs::create_dir_all(&one).unwrap();
624        fs::create_dir_all(&two).unwrap();
625        let (mut imm, a, b) = setup(&one);
626        let (mut bat, a2, b2) = setup(&two);
627        edits(&mut imm, &a, &b);
628
629        bat.set_write_mode(WriteMode::Batched).unwrap();
630        let before = snapshot(bat.root());
631        edits(&mut bat, &a2, &b2);
632        assert!(bat.has_pending_writes());
633        // Only the rename touched the disk: the files moved, no JSON changed.
634        let mid = snapshot(bat.root());
635        let json = |s: &BTreeMap<PathBuf, Vec<u8>>| s.iter().filter(|(p, _)| p.extension().is_some_and(|e| e == "json")).map(|(p, v)| (p.clone(), v.clone())).collect::<BTreeMap<_, _>>();
636        assert_eq!(json(&mid), json(&before));
637        let report = bat.flush().unwrap();
638        assert_eq!(report.items, 2);
639        assert!(report.mtime && report.metadata && report.tags && report.saved_filters);
640        assert!(report.wants_backup());
641        // Flushing never backs up by itself; the app schedules that.
642        assert!(bat.backups().unwrap().is_empty());
643        assert!(!bat.has_pending_writes());
644        assert_eq!(bat.flush().unwrap(), FlushReport::default());
645
646        // Each file now holds what the in-memory model serialises to, the
647        // same as after immediate writes.
648        for lib in [&imm, &bat] {
649            let root = lib.root();
650            assert_eq!(fs::read(root.join("metadata.json")).unwrap(), serde_json::to_vec(&lib.metadata).unwrap());
651            assert_eq!(fs::read(root.join("mtime.json")).unwrap(), serde_json::to_vec(&lib.mtime).unwrap());
652            assert_eq!(fs::read(root.join("tags.json")).unwrap(), serde_json::to_vec(&lib.tags).unwrap());
653            assert_eq!(fs::read(root.join("saved-filters.json")).unwrap(), serde_json::to_vec(&lib.saved_filters).unwrap());
654            for i in lib.items() {
655                assert_eq!(fs::read(i.info_dir(root).join("metadata.json")).unwrap(), serde_json::to_vec(i).unwrap());
656            }
657        }
658        // And the two libraries agree once ids and clocks are set aside.
659        let (mut x, mut y) = (Library::open(imm.root()).unwrap(), Library::open(bat.root()).unwrap());
660        normalise(&mut x);
661        normalise(&mut y);
662        let strip = |l: &Library| {
663            let mut m = l.metadata.clone();
664            m.folders.iter_mut().for_each(|f| {
665                f.id.clear();
666                f.modification_time = 0;
667            });
668            m.tags_groups.iter_mut().for_each(|g| g.id.clear());
669            m
670        };
671        assert_eq!(strip(&x), strip(&y));
672        assert_eq!(x.tags, y.tags);
673        assert_eq!(x.saved_filters, y.saved_filters);
674        let names = |l: &Library| l.items().iter().map(|i| (i.name.clone(), i.tags.clone(), i.is_deleted)).collect::<std::collections::BTreeSet<_>>();
675        assert_eq!(names(&x), names(&y));
676    }
677
678    #[test]
679    fn dropping_a_batched_library_flushes() {
680        let tmp = tempfile::tempdir().unwrap();
681        let (mut lib, a, _) = setup(tmp.path());
682        let root = lib.root().to_path_buf();
683        lib.set_write_mode(WriteMode::Batched).unwrap();
684        let mut i = item(&lib, &a);
685        i.tags.push("kept".into());
686        lib.update_item(i).unwrap();
687        assert!(Library::open(&root).unwrap().item(&a).unwrap().tags.is_empty());
688        drop(lib);
689        assert_eq!(Library::open(&root).unwrap().item(&a).unwrap().tags, ["kept"]);
690    }
691
692    #[test]
693    fn leaving_batched_mode_flushes_and_deleted_items_are_skipped() {
694        let tmp = tempfile::tempdir().unwrap();
695        let (mut lib, a, b) = setup(tmp.path());
696        lib.set_write_mode(WriteMode::Batched).unwrap();
697        lib.set_trashed(&[a.clone(), b.clone()], true).unwrap();
698        lib.delete_permanently(std::slice::from_ref(&a)).unwrap();
699        lib.set_write_mode(WriteMode::Immediate).unwrap();
700        let on_disk = Library::open(lib.root()).unwrap();
701        assert!(on_disk.item(&a).is_none());
702        assert!(on_disk.item(&b).unwrap().is_deleted);
703        assert_eq!(on_disk.mtime, lib.mtime);
704    }
705}