cadmus_core/library/
mod.rs

1mod db;
2mod migrations;
3
4use crate::db::Database;
5use crate::document::{file_kind, SimpleTocEntry};
6use crate::helpers::{Fingerprint, Fp, IsHidden};
7use crate::library::db::Db as LibraryDb;
8use crate::metadata::extract_metadata_from_document;
9use crate::metadata::sorter;
10use crate::metadata::{BookQuery, FileInfo, Info, ReaderInfo, SimpleStatus, SortMethod};
11use crate::settings::ImportSettings;
12use anyhow::{bail, format_err, Error};
13use chrono::Local;
14use fxhash::{FxBuildHasher, FxHashMap};
15use indexmap::IndexMap;
16use std::collections::BTreeSet;
17use std::fs::{self, File};
18use std::io::ErrorKind;
19use std::path::{Path, PathBuf};
20use std::time::{Duration, SystemTime};
21use tracing::{debug, error, info};
22use walkdir::WalkDir;
23
24const METADATA_FILENAME: &str = ".metadata.json";
25const FAT32_EPOCH_FILENAME: &str = ".fat32-epoch";
26const READING_STATES_DIRNAME: &str = ".reading-states";
27#[cfg(not(feature = "test"))]
28const THUMBNAIL_PREVIEWS_DIRNAME: &str = ".thumbnail-previews";
29
30pub struct Library {
31    pub home: PathBuf,
32    pub db: LibraryDb,
33    pub library_id: i64,
34    /// In-memory cache of book metadata keyed by fingerprint.
35    ///
36    /// SQLite is the source of truth, but the UI and library operations rely on
37    /// fast iteration, ordering, and path/fingerprint lookups, so we keep a
38    /// cache of the current library view in memory.
39    pub books: IndexMap<Fp, Info, FxBuildHasher>,
40    /// Reverse index for quick path-to-fingerprint lookups.
41    ///
42    /// This is derived from the cached entries in `books` and is rebuilt on reload.
43    pub paths: FxHashMap<PathBuf, Fp>,
44    pub fat32_epoch: SystemTime,
45    pub sort_method: SortMethod,
46    pub reverse_order: bool,
47    pub show_hidden: bool,
48}
49
50impl Library {
51    #[cfg_attr(feature = "otel", tracing::instrument())]
52    pub fn new<P: AsRef<Path> + std::fmt::Debug>(
53        home: P,
54        database: &Database,
55        name: &str,
56    ) -> Result<Self, Error> {
57        let db = LibraryDb::new(database);
58
59        if let Err(e) = fs::create_dir(&home) {
60            if e.kind() != ErrorKind::AlreadyExists {
61                bail!(e);
62            }
63        }
64
65        let home_path = home.as_ref().to_path_buf();
66        let home_path_str = home_path.to_string_lossy();
67
68        let library_id = if let Some(id) = db.get_library_by_path(&home_path_str)? {
69            info!(library_id = id, path = ?home_path, "found existing library");
70            id
71        } else {
72            let id = db.register_library(&home_path_str, name)?;
73            info!(library_id = id, path = ?home_path, name = %name, "registered new library");
74            id
75        };
76
77        info!(library_id, "loading books from database into cache");
78        let books = db.get_all_books(library_id)?;
79        info!(
80            library_id,
81            count = books.len(),
82            "loaded books from database"
83        );
84
85        let mut book_cache =
86            IndexMap::with_capacity_and_hasher(books.len(), FxBuildHasher::default());
87        let mut paths = FxHashMap::default();
88
89        for (fp, info) in books {
90            paths.insert(info.file.path.clone(), fp);
91            book_cache.insert(fp, info);
92        }
93
94        let path = home.as_ref().join(FAT32_EPOCH_FILENAME);
95        if !path.exists() {
96            let file = File::create(&path)?;
97            file.set_modified(std::time::UNIX_EPOCH + Duration::from_secs(315_532_800))?;
98        }
99
100        let fat32_epoch = path.metadata()?.modified()?;
101
102        let sort_method = SortMethod::Opened;
103
104        Ok(Library {
105            home: home.as_ref().to_path_buf(),
106            db,
107            library_id,
108            books: book_cache,
109            paths,
110            fat32_epoch,
111            sort_method,
112            reverse_order: sort_method.reverse_order(),
113            show_hidden: false,
114        })
115    }
116
117    pub fn list<P: AsRef<Path>>(
118        &self,
119        prefix: P,
120        query: Option<&BookQuery>,
121        skip_files: bool,
122    ) -> (Vec<Info>, BTreeSet<PathBuf>) {
123        let mut dirs = BTreeSet::new();
124        let mut files = Vec::new();
125
126        let relat_prefix = prefix
127            .as_ref()
128            .strip_prefix(&self.home)
129            .unwrap_or_else(|_| prefix.as_ref());
130        for (_, info) in self.books.iter() {
131            if let Ok(relat) = info.file.path.strip_prefix(relat_prefix) {
132                let mut compos = relat.components();
133                let mut first = compos.next();
134                if compos.next().is_none() {
135                    first = None;
136                }
137                if let Some(child) = first {
138                    dirs.insert(prefix.as_ref().join(child.as_os_str()));
139                }
140                if skip_files {
141                    continue;
142                }
143                if query.is_none_or(|q| q.is_match(info)) {
144                    files.push(info.clone());
145                }
146            }
147        }
148
149        (files, dirs)
150    }
151
152    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, settings)))]
153    pub fn import(&mut self, settings: &ImportSettings) {
154        let mut books_to_insert = Vec::new();
155        let mut books_to_update = Vec::new();
156        let mut books_to_delete = Vec::new();
157
158        #[cfg(feature = "otel")]
159        let _walk_span = tracing::info_span!("walk_directory").entered();
160
161        let walk_entries: Vec<_> = WalkDir::new(&self.home)
162            .min_depth(1)
163            .into_iter()
164            .filter_entry(|e| !e.is_hidden())
165            .filter_map(|e| e.ok())
166            .filter(|e| !e.file_type().is_dir())
167            .collect();
168
169        #[cfg(feature = "otel")]
170        let _walk_span = _walk_span.exit();
171
172        #[cfg(feature = "otel")]
173        let _process_span =
174            tracing::info_span!("process_entries", count = walk_entries.len()).entered();
175
176        for entry in walk_entries {
177            let path = entry.path();
178            let relat = path.strip_prefix(&self.home).unwrap_or(path);
179            let md = entry.metadata().unwrap();
180            let fp = md.fingerprint(self.fat32_epoch).unwrap();
181
182            if self.books.contains_key(&fp) {
183                if relat != self.books[&fp].file.path {
184                    debug!(
185                        "Update path for {}: {} → {}.",
186                        fp,
187                        self.books[&fp].file.path.display(),
188                        relat.display()
189                    );
190                    self.paths.remove(&self.books[&fp].file.path);
191                    self.paths.insert(relat.to_path_buf(), fp);
192                    self.books[&fp].file.path = relat.to_path_buf();
193                    self.books[&fp].file.absolute_path = path.to_path_buf();
194                    books_to_update.push(fp);
195                }
196            } else if let Some(fp2) = self.paths.get(relat).cloned() {
197                debug!(
198                    "Update fingerprint for {}: {} → {}.",
199                    relat.display(),
200                    fp2,
201                    fp
202                );
203
204                books_to_delete.push(fp2);
205
206                let mut info = self.books.swap_remove(&fp2).unwrap();
207
208                if settings.sync_metadata && settings.metadata_kinds.contains(&info.file.kind) {
209                    extract_metadata_from_document(&self.home, &mut info);
210                }
211
212                info.file.size = md.len();
213
214                self.books.insert(fp, info);
215                self.paths.insert(relat.to_path_buf(), fp);
216                books_to_insert.push(fp);
217
218                self.db.delete_thumbnail(fp2).ok();
219            } else {
220                let fp1 = self
221                    .fat32_epoch
222                    .checked_sub(Duration::from_secs(1))
223                    .and_then(|epoch| md.fingerprint(epoch).ok())
224                    .unwrap_or(fp);
225                let fp2 = self
226                    .fat32_epoch
227                    .checked_add(Duration::from_secs(1))
228                    .and_then(|epoch| md.fingerprint(epoch).ok())
229                    .unwrap_or(fp);
230
231                let nfp = if fp1 != fp && self.books.contains_key(&fp1) {
232                    Some(fp1)
233                } else if fp2 != fp && self.books.contains_key(&fp2) {
234                    Some(fp2)
235                } else {
236                    None
237                };
238
239                if let Some(nfp) = nfp {
240                    debug!(
241                        "Update fingerprint for {}: {} → {}.",
242                        self.books[&nfp].file.path.display(),
243                        nfp,
244                        fp
245                    );
246
247                    books_to_delete.push(nfp);
248
249                    let info = self.books.swap_remove(&nfp).unwrap();
250                    self.books.insert(fp, info);
251                    books_to_insert.push(fp);
252
253                    self.db.move_thumbnail(nfp, fp).ok();
254                    if relat != self.books[&fp].file.path {
255                        debug!(
256                            "Update path for {}: {} → {}.",
257                            fp,
258                            self.books[&fp].file.path.display(),
259                            relat.display()
260                        );
261                        self.paths.remove(&self.books[&fp].file.path);
262                        self.paths.insert(relat.to_path_buf(), fp);
263                        self.books[&fp].file.path = relat.to_path_buf();
264                        self.books[&fp].file.absolute_path = path.to_path_buf();
265                        books_to_update.push(fp);
266                    }
267                } else {
268                    let kind = file_kind(path).unwrap_or_default();
269                    if !settings.allowed_kinds.contains(&kind) {
270                        continue;
271                    }
272                    info!("Add new entry: {}, {}.", fp, relat.display());
273                    let size = md.len();
274                    let file = FileInfo {
275                        path: relat.to_path_buf(),
276                        absolute_path: path.to_path_buf(),
277                        kind,
278                        size,
279                    };
280                    let mut info = Info {
281                        file,
282                        ..Default::default()
283                    };
284
285                    if settings.metadata_kinds.contains(&info.file.kind) {
286                        extract_metadata_from_document(&self.home, &mut info);
287                    }
288
289                    self.books.insert(fp, info);
290                    self.paths.insert(relat.to_path_buf(), fp);
291                    books_to_insert.push(fp);
292                }
293            }
294        }
295
296        #[cfg(feature = "otel")]
297        let _process_span = _process_span.exit();
298
299        #[cfg(feature = "otel")]
300        let _cleanup_span = tracing::info_span!("cleanup_orphaned_entries").entered();
301
302        let home = &self.home;
303        let mut deleted_fps = Vec::new();
304
305        self.books.retain(|fp, info| {
306            let path = home.join(&info.file.path);
307            if path.exists() {
308                true
309            } else {
310                info!("Remove entry: {}, {}.", fp, info.file.path.display());
311                deleted_fps.push(*fp);
312                false
313            }
314        });
315
316        books_to_delete.extend(deleted_fps.iter().copied());
317
318        for fp in &deleted_fps {
319            self.paths.retain(|_, path_fp| path_fp != fp);
320        }
321
322        #[cfg(feature = "otel")]
323        let _cleanup_span = _cleanup_span.exit();
324
325        #[cfg(feature = "otel")]
326        let _db_span = tracing::info_span!("database_batch_operations").entered();
327
328        if !books_to_insert.is_empty() {
329            let book_refs: Vec<(Fp, &Info)> = books_to_insert
330                .iter()
331                .filter_map(|fp| self.books.get(fp).map(|info| (*fp, info)))
332                .collect();
333
334            if let Err(e) = self.db.batch_insert_books(self.library_id, &book_refs) {
335                error!(
336                    error = %e,
337                    count = book_refs.len(),
338                    "batch insert failed"
339                );
340            }
341        }
342
343        if !books_to_update.is_empty() {
344            let book_refs: Vec<(Fp, &Info)> = books_to_update
345                .iter()
346                .filter_map(|fp| self.books.get(fp).map(|info| (*fp, info)))
347                .collect();
348
349            if let Err(e) = self.db.batch_update_books(self.library_id, &book_refs) {
350                error!(
351                    error = %e,
352                    count = book_refs.len(),
353                    "batch update failed"
354                );
355            }
356        }
357
358        if !books_to_delete.is_empty() {
359            if let Err(e) = self
360                .db
361                .batch_delete_books(self.library_id, &books_to_delete)
362            {
363                error!(
364                    error = %e,
365                    count = books_to_delete.len(),
366                    "batch delete failed"
367                );
368            }
369        }
370    }
371
372    pub fn add_document(&mut self, info: Info) {
373        let path = self.home.join(&info.file.path);
374        let md = path.metadata().unwrap();
375        let fp = md.fingerprint(self.fat32_epoch).unwrap();
376
377        if let Err(e) = self.db.insert_book(self.library_id, fp, &info) {
378            error!(fp = %fp, error = %e, "failed to insert book into database");
379        } else {
380            debug!(fp = %fp, title = %info.title, "book inserted into database");
381        }
382
383        self.paths.insert(info.file.path.clone(), fp);
384        self.books.insert(fp, info);
385    }
386
387    pub fn rename<P: AsRef<Path>>(&mut self, path: P, file_name: &str) -> Result<(), Error> {
388        let src = self.home.join(path.as_ref());
389
390        let fp = self
391            .paths
392            .remove(path.as_ref())
393            .or_else(|| {
394                src.metadata()
395                    .ok()
396                    .and_then(|md| md.fingerprint(self.fat32_epoch).ok())
397            })
398            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
399
400        let mut dest = src.clone();
401        dest.set_file_name(file_name);
402        fs::rename(&src, &dest)?;
403
404        let new_path = dest.strip_prefix(&self.home)?;
405        self.paths.insert(new_path.to_path_buf(), fp);
406        if let Some(info) = self.books.get_mut(&fp) {
407            info.file.path = new_path.to_path_buf();
408            info.file.absolute_path = dest.clone();
409
410            if let Err(e) = self.db.update_book(self.library_id, fp, info) {
411                error!(fp = %fp, error = %e, "failed to update book path in database");
412            } else {
413                debug!(fp = %fp, new_path = %new_path.display(), "book path updated in database");
414            }
415        }
416
417        Ok(())
418    }
419
420    pub fn remove<P: AsRef<Path>>(&mut self, path: P) -> Result<(), Error> {
421        let full_path = self.home.join(path.as_ref());
422
423        let fp = self
424            .paths
425            .get(path.as_ref())
426            .cloned()
427            .or_else(|| {
428                full_path
429                    .metadata()
430                    .ok()
431                    .and_then(|md| md.fingerprint(self.fat32_epoch).ok())
432            })
433            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
434
435        if full_path.exists() {
436            fs::remove_file(&full_path)?;
437        }
438
439        if let Some(parent) = full_path.parent() {
440            if parent != self.home {
441                fs::remove_dir(parent).ok();
442            }
443        }
444
445        self.db.delete_thumbnail(fp).ok();
446
447        if let Err(e) = self.db.delete_book(self.library_id, fp) {
448            error!(fp = %fp, error = %e, "failed to delete book from database");
449        } else {
450            debug!(fp = %fp, "book deleted from database");
451        }
452
453        self.paths.remove(path.as_ref());
454        self.books.shift_remove(&fp);
455
456        Ok(())
457    }
458
459    pub fn copy_to<P: AsRef<Path>>(&mut self, path: P, other: &mut Library) -> Result<(), Error> {
460        let src = self.home.join(path.as_ref());
461
462        if !src.exists() {
463            return Err(format_err!(
464                "can't copy non-existing file {}",
465                path.as_ref().display()
466            ));
467        }
468
469        let md = src.metadata()?;
470        let fp = self
471            .paths
472            .get(path.as_ref())
473            .cloned()
474            .or_else(|| md.fingerprint(self.fat32_epoch).ok())
475            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
476
477        let mut dest = other.home.join(path.as_ref());
478        if let Some(parent) = dest.parent() {
479            fs::create_dir_all(parent)?;
480        }
481
482        if dest.exists() {
483            let prefix = Local::now().format("%Y%m%d_%H%M%S ");
484            let name = dest
485                .file_name()
486                .and_then(|name| name.to_str())
487                .map(|name| prefix.to_string() + name)
488                .ok_or_else(|| format_err!("can't compute new name for {}", dest.display()))?;
489            dest.set_file_name(name);
490        }
491
492        fs::copy(&src, &dest)?;
493        {
494            let fdest = File::open(&dest)?;
495            fdest.set_modified(md.modified()?)?;
496        }
497
498        if let Ok(Some(thumbnail_data)) = self.db.get_thumbnail(fp) {
499            other.db.save_thumbnail(fp, &thumbnail_data).ok();
500        }
501
502        let info = self.books.get(&fp).cloned();
503        if let Some(mut info) = info {
504            let dest_path = dest.strip_prefix(&other.home)?;
505            info.file.path = dest_path.to_path_buf();
506            info.file.absolute_path = dest.clone();
507
508            if let Err(e) = other.db.insert_book(other.library_id, fp, &info) {
509                error!(fp = %fp, error = %e, "failed to insert copied book into target database");
510            } else {
511                debug!(fp = %fp, "book copied to target database");
512            }
513
514            other.books.insert(fp, info);
515            other.paths.insert(dest_path.to_path_buf(), fp);
516        }
517
518        Ok(())
519    }
520
521    pub fn move_to<P: AsRef<Path>>(&mut self, path: P, other: &mut Library) -> Result<(), Error> {
522        let src = self.home.join(path.as_ref());
523
524        if !src.exists() {
525            return Err(format_err!(
526                "can't move non-existing file {}",
527                path.as_ref().display()
528            ));
529        }
530
531        let md = src.metadata()?;
532        let fp = self
533            .paths
534            .get(path.as_ref())
535            .cloned()
536            .or_else(|| md.fingerprint(self.fat32_epoch).ok())
537            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
538
539        let src = self.home.join(path.as_ref());
540        let mut dest = other.home.join(path.as_ref());
541        if let Some(parent) = dest.parent() {
542            fs::create_dir_all(parent)?;
543        }
544
545        if dest.exists() {
546            let prefix = Local::now().format("%Y%m%d_%H%M%S ");
547            let name = dest
548                .file_name()
549                .and_then(|name| name.to_str())
550                .map(|name| prefix.to_string() + name)
551                .ok_or_else(|| format_err!("can't compute new name for {}", dest.display()))?;
552            dest.set_file_name(name);
553        }
554
555        fs::rename(&src, &dest)?;
556
557        let thumbnail_data = self.db.get_thumbnail(fp).ok().flatten();
558
559        let info = self.books.shift_remove(&fp);
560        if let Some(mut info) = info {
561            let dest_path = dest.strip_prefix(&other.home)?;
562            info.file.path = dest_path.to_path_buf();
563            info.file.absolute_path = dest.clone();
564
565            if let Err(e) = other.db.insert_book(other.library_id, fp, &info) {
566                error!(fp = %fp, error = %e, "failed to insert moved book into target database");
567            } else {
568                debug!(fp = %fp, "book moved to target database");
569            }
570
571            if let Some(thumbnail_data) = thumbnail_data {
572                other.db.save_thumbnail(fp, &thumbnail_data).ok();
573            }
574
575            if let Err(e) = self.db.delete_book(self.library_id, fp) {
576                error!(fp = %fp, error = %e, "failed to delete moved book from source database");
577            }
578
579            other.books.insert(fp, info);
580            self.paths.remove(path.as_ref());
581            other.paths.insert(dest_path.to_path_buf(), fp);
582        }
583
584        Ok(())
585    }
586
587    /// No-op for the database-backed library: the database maintains its own consistency.
588    pub fn clean_up(&mut self) {}
589
590    pub fn sort(&mut self, sort_method: SortMethod, reverse_order: bool) {
591        self.sort_method = sort_method;
592        self.reverse_order = reverse_order;
593
594        let sort_fn = sorter(sort_method);
595
596        if reverse_order {
597            self.books.sort_by(|_, a, _, b| sort_fn(a, b).reverse());
598        } else {
599            self.books.sort_by(|_, a, _, b| sort_fn(a, b));
600        }
601    }
602
603    pub fn apply<F>(&mut self, f: F)
604    where
605        F: Fn(&Path, &mut Info),
606    {
607        for (_, info) in &mut self.books {
608            f(&self.home, info);
609        }
610    }
611
612    pub fn sync_reader_info<P: AsRef<Path>>(&mut self, path: P, reader: &ReaderInfo) {
613        let fp = self.paths.get(path.as_ref()).cloned().unwrap_or_else(|| {
614            self.home
615                .join(path.as_ref())
616                .metadata()
617                .unwrap()
618                .fingerprint(self.fat32_epoch)
619                .unwrap()
620        });
621
622        if let Err(e) = self.db.save_reading_state(fp, reader) {
623            error!(fp = %fp, error = %e, "failed to save reading state to database");
624        } else {
625            debug!(fp = %fp, "reading state saved to database");
626        }
627
628        if let Some(info) = self.books.get_mut(&fp) {
629            info.reader = Some(reader.clone());
630        }
631    }
632
633    /// Persist a book's TOC to the database and update the in-memory entry.
634    ///
635    /// Call this when a TOC has been parsed from a document for the first time
636    /// so subsequent opens can serve it from the database without re-parsing.
637    pub fn sync_toc<P: AsRef<Path>>(&mut self, path: P, toc: Vec<SimpleTocEntry>) {
638        let fp = self.paths.get(path.as_ref()).cloned().unwrap_or_else(|| {
639            self.home
640                .join(path.as_ref())
641                .metadata()
642                .unwrap()
643                .fingerprint(self.fat32_epoch)
644                .unwrap()
645        });
646
647        if let Err(e) = self.db.save_toc(fp, &toc) {
648            error!(fp = %fp, error = %e, "failed to save TOC to database");
649        } else {
650            debug!(fp = %fp, entry_count = toc.len(), "TOC saved to database");
651        }
652
653        if let Some(info) = self.books.get_mut(&fp) {
654            info.toc = Some(toc);
655        }
656    }
657
658    pub fn thumbnail_preview<P: AsRef<Path>>(&self, path: P) -> Option<crate::framebuffer::Pixmap> {
659        let fp = self.fingerprint_for_path(path.as_ref())?;
660        match self.db.get_thumbnail(fp) {
661            Ok(Some(data)) => crate::framebuffer::Pixmap::from_png_bytes(&data).ok(),
662            Ok(None) => None,
663            Err(e) => {
664                error!(fp = %fp, error = %e, "failed to load thumbnail from database");
665                None
666            }
667        }
668    }
669
670    pub fn set_status<P: AsRef<Path>>(&mut self, path: P, status: SimpleStatus) {
671        let fp = self.paths.get(path.as_ref()).cloned().unwrap_or_else(|| {
672            self.home
673                .join(path.as_ref())
674                .metadata()
675                .unwrap()
676                .fingerprint(self.fat32_epoch)
677                .unwrap()
678        });
679
680        match status {
681            SimpleStatus::New => {
682                if let Some(info) = self.books.get_mut(&fp) {
683                    info.reader = None;
684                }
685
686                if let Err(e) = self.db.delete_reading_state(fp) {
687                    error!(fp = %fp, error = %e, "failed to delete reading state from database");
688                }
689            }
690            SimpleStatus::Reading | SimpleStatus::Finished => {
691                if let Some(info) = self.books.get_mut(&fp) {
692                    let reader_info = info.reader.get_or_insert_with(ReaderInfo::default);
693                    reader_info.finished = status == SimpleStatus::Finished;
694
695                    if let Err(e) = self.db.save_reading_state(fp, reader_info) {
696                        error!(fp = %fp, error = %e, "failed to save reading state to database");
697                    } else {
698                        debug!(fp = %fp, finished = reader_info.finished, "reading state updated in database");
699                    }
700                }
701            }
702        }
703    }
704
705    pub fn reload(&mut self) {
706        self.books.clear();
707        self.paths.clear();
708
709        match self.db.get_all_books(self.library_id) {
710            Err(e) => {
711                error!(error = %e, "failed to reload books from database");
712            }
713            Ok(books) => {
714                debug!(count = books.len(), "reloaded books from database");
715                for (fp, info) in books {
716                    self.paths.insert(info.file.path.clone(), fp);
717                    self.books.insert(fp, info);
718                }
719            }
720        }
721    }
722
723    /// No-op: database writes are immediate and do not require an explicit flush.
724    pub fn flush(&mut self) {}
725
726    pub fn is_empty(&self) -> Option<bool> {
727        Some(self.books.is_empty())
728    }
729
730    fn fingerprint_for_path(&self, path: &Path) -> Option<Fp> {
731        self.paths.get(path).cloned().or_else(|| {
732            self.home
733                .join(path)
734                .metadata()
735                .ok()
736                .and_then(|md| md.fingerprint(self.fat32_epoch).ok())
737        })
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use crate::db::Database;
745    use crate::settings::ImportSettings;
746
747    fn setup_library_with_book(
748        dir: &Path,
749        db: &Database,
750        name: &str,
751        filename: &str,
752    ) -> (Library, PathBuf) {
753        let mut lib = Library::new(dir, db, name).expect("failed to create library");
754        fs::write(dir.join(filename), b"dummy book content").expect("failed to write test file");
755        lib.import(&ImportSettings::default());
756        (lib, PathBuf::from(filename))
757    }
758
759    #[test]
760    fn copy_to_sets_absolute_path_in_destination() {
761        let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
762        let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
763        let db = Database::new(":memory:").expect("failed to create in-memory database");
764        db.migrate().expect("failed to run migrations");
765
766        let (mut src_lib, rel_path) =
767            setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
768        let mut dst_lib =
769            Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
770
771        assert!(
772            src_lib.books.values().next().is_some(),
773            "source library should contain the book"
774        );
775
776        src_lib
777            .copy_to(&rel_path, &mut dst_lib)
778            .expect("copy_to failed");
779
780        let dst_info = dst_lib
781            .books
782            .values()
783            .next()
784            .expect("destination library should contain the copied book");
785
786        let expected_abs = dst_dir.path().join(&dst_info.file.path);
787        assert_eq!(
788            dst_info.file.absolute_path, expected_abs,
789            "absolute_path should point to the destination file after copy_to"
790        );
791        assert!(
792            dst_info.file.absolute_path.exists(),
793            "absolute_path should point to an existing file"
794        );
795    }
796
797    #[test]
798    fn move_to_sets_absolute_path_in_destination() {
799        let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
800        let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
801        let db = Database::new(":memory:").expect("failed to create in-memory database");
802        db.migrate().expect("failed to run migrations");
803
804        let (mut src_lib, rel_path) =
805            setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
806        let mut dst_lib =
807            Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
808
809        assert!(
810            src_lib.books.values().next().is_some(),
811            "source library should contain the book"
812        );
813
814        src_lib
815            .move_to(&rel_path, &mut dst_lib)
816            .expect("move_to failed");
817
818        assert!(
819            src_lib.books.is_empty(),
820            "source library should no longer contain the book after move"
821        );
822
823        let dst_info = dst_lib
824            .books
825            .values()
826            .next()
827            .expect("destination library should contain the moved book");
828
829        let expected_abs = dst_dir.path().join(&dst_info.file.path);
830        assert_eq!(
831            dst_info.file.absolute_path, expected_abs,
832            "absolute_path should point to the destination file after move_to"
833        );
834        assert!(
835            dst_info.file.absolute_path.exists(),
836            "absolute_path should point to an existing file"
837        );
838    }
839}