Skip to main content

cadmus_core/library/
mod.rs

1pub(crate) mod db;
2pub mod importer;
3mod migrations;
4
5use crate::db::Database;
6use crate::document::SimpleTocEntry;
7use crate::helpers::{Fingerprint, Fp};
8use crate::library::db::Db as LibraryDb;
9use crate::metadata::sorter;
10use crate::metadata::{BookQuery, Info, ReaderInfo, SimpleStatus, SortMethod};
11use anyhow::{bail, format_err, Error};
12use chrono::Local;
13use std::collections::BTreeSet;
14use std::fs;
15use std::io::ErrorKind;
16use std::path::{Path, PathBuf};
17use tracing::{debug, error, info};
18
19pub(crate) const METADATA_FILENAME: &str = ".metadata.json";
20pub(crate) const READING_STATES_DIRNAME: &str = ".reading-states";
21#[cfg(not(feature = "test"))]
22pub(crate) const THUMBNAIL_PREVIEWS_DIRNAME: &str = ".thumbnail-previews";
23
24#[derive(Debug, Clone, Default)]
25pub struct PageResult {
26    pub books: Vec<Info>,
27    pub total_count: usize,
28}
29
30pub struct Library {
31    pub home: PathBuf,
32    pub db: LibraryDb,
33    pub library_id: i64,
34    pub sort_method: SortMethod,
35    pub reverse_order: bool,
36    pub show_hidden: bool,
37}
38
39impl Library {
40    #[cfg_attr(feature = "tracing", tracing::instrument())]
41    pub fn new<P: AsRef<Path> + std::fmt::Debug>(
42        home: P,
43        database: &Database,
44        name: &str,
45    ) -> Result<Self, Error> {
46        let db = LibraryDb::new(database);
47
48        if let Err(e) = fs::create_dir(&home) {
49            if e.kind() != ErrorKind::AlreadyExists {
50                bail!(e);
51            }
52        }
53
54        let home_path = home.as_ref().to_path_buf();
55        let home_path_str = home_path.to_string_lossy();
56
57        let library_id = if let Some(id) = db.get_library_by_path(&home_path_str)? {
58            info!(library_id = id, path = ?home_path, "found existing library");
59            id
60        } else {
61            let id = db.register_library(&home_path_str, name)?;
62            info!(library_id = id, path = ?home_path, name = %name, "registered new library");
63            id
64        };
65
66        let sort_method = SortMethod::Opened;
67
68        Ok(Library {
69            home: home.as_ref().to_path_buf(),
70            db,
71            library_id,
72            sort_method,
73            reverse_order: sort_method.reverse_order(),
74            show_hidden: false,
75        })
76    }
77
78    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, query, prefix)))]
79    pub fn list<P: AsRef<Path>>(
80        &self,
81        prefix: P,
82        query: Option<&BookQuery>,
83        skip_files: bool,
84    ) -> (Vec<Info>, BTreeSet<PathBuf>) {
85        self.list_by(
86            prefix,
87            query,
88            self.sort_method,
89            self.reverse_order,
90            skip_files,
91        )
92    }
93
94    /// Lists books and direct subdirectories under `prefix` using explicit sort parameters.
95    ///
96    /// When no query is active, sorting is delegated to SQLite. When a query is active it
97    /// cannot be expressed in SQL, so books are loaded in full and sorted in Rust.
98    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, query, prefix)))]
99    pub fn list_by<P: AsRef<Path>>(
100        &self,
101        prefix: P,
102        query: Option<&BookQuery>,
103        sort_method: SortMethod,
104        reverse_order: bool,
105        skip_files: bool,
106    ) -> (Vec<Info>, BTreeSet<PathBuf>) {
107        let relat_prefix = prefix
108            .as_ref()
109            .strip_prefix(&self.home)
110            .unwrap_or_else(|_| prefix.as_ref());
111
112        let dirs = self
113            .db
114            .list_directories_under_prefix(self.library_id, relat_prefix)
115            .map_err(|e| {
116                error!(error = %e, library_id = self.library_id, "failed to list directories");
117            })
118            .unwrap_or_default()
119            .into_iter()
120            .map(|path| prefix.as_ref().join(path))
121            .collect();
122
123        if skip_files {
124            return (Vec::new(), dirs);
125        }
126
127        let files = if query.is_none() {
128            self.db
129                .page_books(
130                    self.library_id,
131                    relat_prefix,
132                    sort_method,
133                    reverse_order,
134                    i64::MAX,
135                    0,
136                )
137                .map_err(|e| {
138                    error!(error = %e, library_id = self.library_id, "failed to list books");
139                })
140                .map(|(books, _)| books)
141                .unwrap_or_default()
142        } else {
143            let cmp = sorter(sort_method);
144            let mut books: Vec<Info> = self
145                .db
146                .list_books_under_prefix(self.library_id, relat_prefix)
147                .map_err(|e| {
148                    error!(error = %e, library_id = self.library_id, "failed to list books");
149                })
150                .unwrap_or_default()
151                .into_iter()
152                .filter(|info| query.is_none_or(|q| q.is_match(info)))
153                .collect();
154            if reverse_order {
155                books.sort_by(|a, b| cmp(b, a));
156            } else {
157                books.sort_by(cmp);
158            }
159            books
160        };
161
162        (files, dirs)
163    }
164
165    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, prefix, query)))]
166    pub fn page<P: AsRef<Path>>(
167        &self,
168        prefix: P,
169        query: Option<&BookQuery>,
170        page: usize,
171        page_size: usize,
172    ) -> Result<PageResult, Error> {
173        if page_size == 0 {
174            return Ok(PageResult::default());
175        }
176
177        if query.is_some() {
178            let (files, _) = self.list(prefix, query, false);
179            let total_count = files.len();
180            let start = page.saturating_mul(page_size);
181            let books = files.into_iter().skip(start).take(page_size).collect();
182            return Ok(PageResult { books, total_count });
183        }
184
185        let relat_prefix = prefix
186            .as_ref()
187            .strip_prefix(&self.home)
188            .unwrap_or_else(|_| prefix.as_ref());
189        let offset = (page.saturating_mul(page_size)) as i64;
190        let limit = page_size as i64;
191
192        let (books, total_count) = self.db.page_books(
193            self.library_id,
194            relat_prefix,
195            self.sort_method,
196            self.reverse_order,
197            limit,
198            offset,
199        )?;
200
201        Ok(PageResult {
202            books,
203            total_count: total_count as usize,
204        })
205    }
206
207    /// Finds the next or previous results page where the visible status changes.
208    ///
209    /// When browsing through a paginated list of books, this function helps locate
210    /// the boundary page where the [`SimpleStatus`] (New, Reading, or Finished)
211    /// changes from one value to another.
212    ///
213    /// # Arguments
214    ///
215    /// * `prefix` - Path prefix to filter books within a specific directory
216    /// * `query` - Optional filter query to apply (e.g., by title, author)
217    /// * `current_page` - The page number we're currently viewing (0-indexed)
218    /// * `page_size` - Number of books per page
219    /// * `dir` - Direction to search: [`crate::geom::CycleDir::Next`] or [`crate::geom::CycleDir::Previous`]
220    ///
221    /// # Returns
222    ///
223    /// `Ok(Some(page_number))` where the status changes, or `Ok(None)` if no
224    /// status change is found in that direction.
225    ///
226    /// # Example
227    ///
228    /// Suppose books are sorted and paginated with 20 books per page:
229    /// - Page 0: books 0-19 (status: New)
230    /// - Page 1: books 20-39 (status: New)
231    /// - Page 2: books 40-59 (status: Reading)
232    /// - Page 3: books 60-79 (status: Finished)
233    ///
234    /// If currently on page 1 looking for the next status change with
235    /// `CycleDir::Next`, the function examines the last book on page 1 (book 19,
236    /// status `New`), then scans forward until it finds book 40 with status
237    /// `Reading`. It returns `Ok(Some(2))` - the page where the status first
238    /// differs.
239    ///
240    /// Similarly, with `CycleDir::Previous` from page 3, it examines book 60
241    /// (status `Finished`) and scans backward to find the boundary, returning
242    /// `Ok(Some(2))`.
243    ///
244    /// Returns `Ok(None)` if there is no status change in the requested direction
245    /// (e.g., searching forward from the last page of uniform status).
246    pub fn neighbor_status_change_page<P: AsRef<Path>>(
247        &self,
248        prefix: P,
249        query: Option<&BookQuery>,
250        current_page: usize,
251        page_size: usize,
252        dir: crate::geom::CycleDir,
253    ) -> Result<Option<usize>, Error> {
254        if page_size == 0 {
255            return Ok(None);
256        }
257
258        let (files, _) = self.list(prefix, query, false);
259
260        if files.is_empty() || current_page >= files.len().div_ceil(page_size) {
261            return Ok(None);
262        }
263
264        let index_lower = current_page.saturating_mul(page_size);
265        let index_upper = (index_lower + page_size).min(files.len());
266        if index_lower >= files.len() || index_upper == 0 {
267            return Ok(None);
268        }
269
270        let book_index = match dir {
271            crate::geom::CycleDir::Next => index_upper.saturating_sub(1),
272            crate::geom::CycleDir::Previous => index_lower,
273        };
274        let status = files[book_index].simple_status();
275
276        let page = match dir {
277            crate::geom::CycleDir::Next => files[book_index + 1..]
278                .iter()
279                .position(|info| info.simple_status() != status)
280                .map(|delta| current_page + 1 + delta / page_size),
281            crate::geom::CycleDir::Previous => files[..book_index]
282                .iter()
283                .rev()
284                .position(|info| info.simple_status() != status)
285                .map(|delta| current_page.saturating_sub(1 + delta / page_size)),
286        };
287
288        Ok(page)
289    }
290
291    pub fn resolve_id(&self) -> (i64, &std::path::Path) {
292        (self.library_id, &self.home)
293    }
294
295    pub fn add_document(&mut self, info: Info) {
296        let path = self.home.join(&info.file.path);
297        let fp = match path.fingerprint() {
298            Ok(fp) => fp,
299            Err(e) => {
300                error!(path = %path.display(), error = %e, "failed to fingerprint document");
301                return;
302            }
303        };
304
305        if let Err(e) = self.db.insert_book(self.library_id, fp, &info) {
306            error!(fp = %fp, error = %e, "failed to insert book into database");
307            return;
308        }
309
310        debug!(fp = %fp, title = %info.title, "book inserted into database");
311
312        if let Err(e) = self.db.insert_sort_rank(self.library_id, fp, &info) {
313            error!(fp = %fp, error = %e, "failed to insert sort rank for new book");
314        }
315    }
316
317    pub fn rename<P: AsRef<Path>>(&mut self, path: P, file_name: &str) -> Result<(), Error> {
318        let src = self.home.join(path.as_ref());
319
320        let fp = self
321            .resolve_fingerprint(path.as_ref())
322            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
323
324        let mut dest = src.clone();
325        dest.set_file_name(file_name);
326        fs::rename(&src, &dest)?;
327
328        let new_path = dest.strip_prefix(&self.home)?;
329
330        if let Some(mut info) = self.db.get_book_by_fingerprint(self.library_id, fp)? {
331            info.file.path = new_path.to_path_buf();
332            info.file.absolute_path = dest.clone();
333
334            if let Err(e) = self.db.update_book(self.library_id, fp, &info) {
335                error!(fp = %fp, error = %e, "failed to update book path in database");
336            } else {
337                debug!(fp = %fp, new_path = %new_path.display(), "book path updated in database");
338
339                if let Err(e) = self.db.insert_sort_rank(self.library_id, fp, &info) {
340                    error!(fp = %fp, error = %e, "failed to update sort rank after rename");
341                }
342            }
343        }
344
345        Ok(())
346    }
347
348    pub fn remove<P: AsRef<Path>>(&mut self, path: P) -> Result<(), Error> {
349        let full_path = self.home.join(path.as_ref());
350
351        let fp = self
352            .resolve_fingerprint(path.as_ref())
353            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
354
355        if full_path.exists() {
356            fs::remove_file(&full_path)?;
357        }
358
359        if let Some(parent) = full_path.parent() {
360            if parent != self.home {
361                fs::remove_dir(parent).ok();
362            }
363        }
364
365        self.db.delete_thumbnail(fp).ok();
366
367        if let Err(e) = self.db.delete_book(self.library_id, fp) {
368            error!(fp = %fp, error = %e, "failed to delete book from database");
369        } else {
370            debug!(fp = %fp, "book deleted from database");
371        }
372
373        Ok(())
374    }
375
376    pub fn copy_to<P: AsRef<Path>>(&mut self, path: P, other: &mut Library) -> Result<(), Error> {
377        let src = self.home.join(path.as_ref());
378
379        if !src.exists() {
380            return Err(format_err!(
381                "can't copy non-existing file {}",
382                path.as_ref().display()
383            ));
384        }
385
386        let fp = self
387            .resolve_fingerprint(path.as_ref())
388            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
389
390        let mut dest = other.home.join(path.as_ref());
391        if let Some(parent) = dest.parent() {
392            fs::create_dir_all(parent)?;
393        }
394
395        if dest.exists() {
396            let prefix = Local::now().format("%Y%m%d_%H%M%S ");
397            let name = dest
398                .file_name()
399                .and_then(|name| name.to_str())
400                .map(|name| prefix.to_string() + name)
401                .ok_or_else(|| format_err!("can't compute new name for {}", dest.display()))?;
402            dest.set_file_name(name);
403        }
404
405        fs::copy(&src, &dest)?;
406
407        if let Ok(Some(thumbnail_data)) = self.db.get_thumbnail(fp) {
408            other.db.save_thumbnail(fp, &thumbnail_data).ok();
409        }
410
411        if let Some(mut info) = self.db.get_book_by_fingerprint(self.library_id, fp)? {
412            let dest_path = dest.strip_prefix(&other.home)?;
413            info.file.path = dest_path.to_path_buf();
414            info.file.absolute_path = dest.clone();
415
416            if let Err(e) = other.db.insert_book(other.library_id, fp, &info) {
417                error!(fp = %fp, error = %e, "failed to insert copied book into target database");
418            } else {
419                debug!(fp = %fp, "book copied to target database");
420
421                if let Err(e) = other.db.insert_sort_rank(other.library_id, fp, &info) {
422                    error!(fp = %fp, error = %e, "failed to insert sort rank for copied book");
423                }
424            }
425        }
426
427        Ok(())
428    }
429
430    pub fn move_to<P: AsRef<Path>>(&mut self, path: P, other: &mut Library) -> Result<(), Error> {
431        let src = self.home.join(path.as_ref());
432
433        if !src.exists() {
434            return Err(format_err!(
435                "can't move non-existing file {}",
436                path.as_ref().display()
437            ));
438        }
439
440        let fp = self
441            .resolve_fingerprint(path.as_ref())
442            .ok_or_else(|| format_err!("can't get fingerprint of {}", path.as_ref().display()))?;
443
444        let src = self.home.join(path.as_ref());
445        let mut dest = other.home.join(path.as_ref());
446        if let Some(parent) = dest.parent() {
447            fs::create_dir_all(parent)?;
448        }
449
450        if dest.exists() {
451            let prefix = Local::now().format("%Y%m%d_%H%M%S ");
452            let name = dest
453                .file_name()
454                .and_then(|name| name.to_str())
455                .map(|name| prefix.to_string() + name)
456                .ok_or_else(|| format_err!("can't compute new name for {}", dest.display()))?;
457            dest.set_file_name(name);
458        }
459
460        fs::rename(&src, &dest)?;
461
462        let thumbnail_data = self.db.get_thumbnail(fp).ok().flatten();
463
464        if let Some(mut info) = self.db.get_book_by_fingerprint(self.library_id, fp)? {
465            let dest_path = dest.strip_prefix(&other.home)?;
466            info.file.path = dest_path.to_path_buf();
467            info.file.absolute_path = dest.clone();
468
469            if let Err(e) = other.db.insert_book(other.library_id, fp, &info) {
470                error!(fp = %fp, error = %e, "failed to insert moved book into target database");
471            } else {
472                debug!(fp = %fp, "book moved to target database");
473
474                if let Err(e) = other.db.insert_sort_rank(other.library_id, fp, &info) {
475                    error!(fp = %fp, error = %e, "failed to insert sort rank for moved book");
476                }
477            }
478
479            if let Some(thumbnail_data) = thumbnail_data {
480                other.db.save_thumbnail(fp, &thumbnail_data).ok();
481            }
482
483            if let Err(e) = self.db.delete_book(self.library_id, fp) {
484                error!(fp = %fp, error = %e, "failed to delete moved book from source database");
485            }
486        }
487
488        Ok(())
489    }
490
491    /// No-op for the database-backed library: the database maintains its own consistency.
492    pub fn clean_up(&mut self) {}
493
494    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
495    pub fn set_sort(&mut self, sort_method: SortMethod, reverse_order: bool) {
496        self.sort_method = sort_method;
497        self.reverse_order = reverse_order;
498    }
499
500    pub fn apply<F>(&mut self, f: F)
501    where
502        F: Fn(&Path, &mut Info),
503    {
504        let books = match self.db.get_all_books(self.library_id) {
505            Ok(b) => b,
506            Err(e) => {
507                error!(error = %e, "failed to load books for apply");
508                return;
509            }
510        };
511
512        let updated: Vec<Info> = books
513            .into_iter()
514            .map(|mut info| {
515                f(&self.home, &mut info);
516                info
517            })
518            .collect();
519
520        let refs: Vec<(Fp, &Info)> = updated
521            .iter()
522            .filter_map(|info| info.fp.map(|fp| (fp, info)))
523            .collect();
524
525        if let Err(e) = self.db.batch_update_books(self.library_id, &refs) {
526            error!(error = %e, "failed to persist apply changes to database");
527        }
528    }
529
530    pub fn sync_reader_info<P: AsRef<Path>>(&mut self, path: P, reader: &ReaderInfo) {
531        let path = path.as_ref();
532        let Some(fp) = self.resolve_fingerprint(path) else {
533            return;
534        };
535
536        if let Err(e) = self.db.save_reading_state(fp, reader) {
537            error!(fp = %fp, error = %e, "failed to save reading state to database");
538        } else {
539            debug!(fp = %fp, "reading state saved to database");
540        }
541    }
542
543    /// Persist a book's TOC to the database.
544    ///
545    /// Call this when a TOC has been parsed from a document for the first time
546    /// so subsequent opens can serve it from the database without re-parsing.
547    pub fn sync_toc<P: AsRef<Path>>(&mut self, path: P, toc: Vec<SimpleTocEntry>) {
548        let path = path.as_ref();
549        let Some(fp) = self.resolve_fingerprint(path) else {
550            return;
551        };
552
553        if let Err(e) = self.db.save_toc(fp, &toc) {
554            error!(fp = %fp, error = %e, "failed to save TOC to database");
555        } else {
556            debug!(fp = %fp, entry_count = toc.len(), "TOC saved to database");
557        }
558    }
559
560    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
561    pub fn thumbnail_preview<P: AsRef<Path> + std::fmt::Debug>(
562        &self,
563        path: P,
564    ) -> Option<crate::framebuffer::Pixmap> {
565        match self
566            .db
567            .get_thumbnail_by_path(self.library_id, path.as_ref())
568        {
569            Ok(Some(data)) => crate::framebuffer::Pixmap::from_png_bytes(&data).ok(),
570            Ok(None) => None,
571            Err(e) => {
572                error!(library_id = self.library_id, path = %path.as_ref().display(), error = %e, "failed to load thumbnail from database");
573                None
574            }
575        }
576    }
577
578    pub fn set_status<P: AsRef<Path>>(&mut self, path: P, status: SimpleStatus) {
579        let path = path.as_ref();
580        let Some(fp) = self.resolve_fingerprint(path) else {
581            return;
582        };
583
584        match status {
585            SimpleStatus::New => {
586                if let Err(e) = self.db.delete_reading_state(fp) {
587                    error!(fp = %fp, error = %e, "failed to delete reading state from database");
588                }
589            }
590            SimpleStatus::Reading | SimpleStatus::Finished => {
591                let current_info = self
592                    .db
593                    .get_book_by_fingerprint(self.library_id, fp)
594                    .ok()
595                    .flatten();
596
597                let mut reader_info = current_info
598                    .and_then(|info| info.reader)
599                    .unwrap_or_default();
600
601                reader_info.finished = status == SimpleStatus::Finished;
602
603                if let Err(e) = self.db.save_reading_state(fp, &reader_info) {
604                    error!(fp = %fp, error = %e, "failed to save reading state to database");
605                } else {
606                    debug!(fp = %fp, finished = reader_info.finished, "reading state updated in database");
607                }
608            }
609        }
610    }
611
612    /// No-op: the database is the source of truth and requires no explicit cache reload.
613    pub fn reload(&mut self) {}
614
615    /// No-op: database writes are immediate and do not require an explicit flush.
616    pub fn flush(&mut self) {}
617
618    pub fn is_empty(&self) -> Option<bool> {
619        self.db
620            .count_books(self.library_id)
621            .ok()
622            .map(|count| count == 0)
623    }
624
625    pub fn next_book_after(&self, fp: Fp) -> Option<Info> {
626        let mut books: Vec<Info> = self
627            .db
628            .list_books_under_prefix(self.library_id, Path::new(""))
629            .ok()?;
630
631        if books.is_empty() {
632            return None;
633        }
634
635        books.sort_by(|left, right| {
636            let ordering = sorter(self.sort_method)(left, right);
637            if self.reverse_order {
638                ordering.reverse()
639            } else {
640                ordering
641            }
642        });
643
644        let current_index = books
645            .iter()
646            .position(|candidate| candidate.fp == Some(fp))?;
647        books.into_iter().nth(current_index + 1)
648    }
649
650    pub fn most_recently_opened_reading_book(&self) -> Option<Info> {
651        self.db
652            .most_recently_opened_reading_book(self.library_id)
653            .map_err(|e| {
654                error!(error = %e, library_id = self.library_id, "failed to get most recently opened reading book");
655            })
656            .ok()
657            .flatten()
658    }
659
660    fn resolve_fingerprint(&self, path: &Path) -> Option<Fp> {
661        match self.db.get_book_by_path(self.library_id, path) {
662            Ok(Some(info)) => {
663                if let Some(fp) = info.fp {
664                    return Some(fp);
665                }
666            }
667            Ok(None) => {}
668            Err(e) => {
669                error!(
670                    path = %path.display(),
671                    error = %e,
672                    "failed to resolve fingerprint from database"
673                );
674            }
675        }
676
677        let full_path = self.home.join(path);
678
679        match full_path.fingerprint() {
680            Ok(fp) => Some(fp),
681            Err(e) => {
682                error!(path = %full_path.display(), error = %e, "failed to fingerprint path");
683                None
684            }
685        }
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::db::Database;
693    use crate::geom::CycleDir;
694    use crate::metadata::FileInfo;
695    use crate::settings::ImportSettings;
696    use crate::task::ShutdownSignal;
697    use std::str::FromStr;
698    use std::sync::mpsc;
699
700    fn setup_library_with_book(
701        dir: &Path,
702        db: &Database,
703        name: &str,
704        filename: &str,
705    ) -> (Library, PathBuf) {
706        let lib = Library::new(dir, db, name).expect("failed to create library");
707        fs::write(dir.join(filename), b"dummy book content").expect("failed to write test file");
708        let (tx, _rx) = mpsc::channel();
709        let notif_id = crate::view::ViewId::MessageNotif(0);
710        let shutdown = ShutdownSignal::never();
711        importer::run(
712            &lib.db,
713            lib.library_id,
714            dir,
715            &ImportSettings::default(),
716            &tx,
717            notif_id,
718            &shutdown,
719        );
720        (lib, PathBuf::from(filename))
721    }
722
723    fn make_info(path: &str, title: &str, fp: Fp) -> Info {
724        Info {
725            title: title.to_string(),
726            file: FileInfo {
727                path: PathBuf::from(path),
728                absolute_path: PathBuf::from(format!("/library/{path}")),
729                kind: "pdf".to_string(),
730                size: 1024,
731            },
732            fp: Some(fp),
733            ..Default::default()
734        }
735    }
736
737    fn make_status_info(path: &str, title: &str, fp: Fp, status: SimpleStatus) -> Info {
738        let mut info = make_info(path, title, fp);
739        let reader = match status {
740            SimpleStatus::New => None,
741            SimpleStatus::Reading => Some(ReaderInfo {
742                current_page: 1,
743                pages_count: 10,
744                finished: false,
745                ..Default::default()
746            }),
747            SimpleStatus::Finished => Some(ReaderInfo {
748                current_page: 10,
749                pages_count: 10,
750                finished: true,
751                ..Default::default()
752            }),
753        };
754        info.reader = reader.clone();
755        info.reader_info = reader;
756        info
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        let src_books = src_lib
772            .db
773            .get_all_books(src_lib.library_id)
774            .expect("failed to get src books");
775        assert!(
776            !src_books.is_empty(),
777            "source library should contain the book"
778        );
779
780        src_lib
781            .copy_to(&rel_path, &mut dst_lib)
782            .expect("copy_to failed");
783
784        let dst_books = dst_lib
785            .db
786            .get_all_books(dst_lib.library_id)
787            .expect("failed to get dst books");
788
789        let dst_info = dst_books
790            .into_iter()
791            .next()
792            .expect("destination library should contain the copied book");
793
794        let expected_abs = dst_dir.path().join(&dst_info.file.path);
795        assert_eq!(
796            dst_info.file.absolute_path, expected_abs,
797            "absolute_path should point to the destination file after copy_to"
798        );
799        assert!(
800            dst_info.file.absolute_path.exists(),
801            "absolute_path should point to an existing file"
802        );
803    }
804
805    #[test]
806    fn move_to_sets_absolute_path_in_destination() {
807        let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
808        let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
809        let db = Database::new(":memory:").expect("failed to create in-memory database");
810        db.migrate().expect("failed to run migrations");
811
812        let (mut src_lib, rel_path) =
813            setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
814        let mut dst_lib =
815            Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
816
817        let src_books = src_lib
818            .db
819            .get_all_books(src_lib.library_id)
820            .expect("failed to get src books");
821        assert!(
822            !src_books.is_empty(),
823            "source library should contain the book"
824        );
825
826        src_lib
827            .move_to(&rel_path, &mut dst_lib)
828            .expect("move_to failed");
829
830        let src_books_after = src_lib
831            .db
832            .get_all_books(src_lib.library_id)
833            .expect("failed to get src books after move");
834        assert!(
835            src_books_after.is_empty(),
836            "source library should no longer contain the book after move"
837        );
838
839        let dst_books = dst_lib
840            .db
841            .get_all_books(dst_lib.library_id)
842            .expect("failed to get dst books");
843
844        let dst_info = dst_books
845            .into_iter()
846            .next()
847            .expect("destination library should contain the moved book");
848
849        let expected_abs = dst_dir.path().join(&dst_info.file.path);
850        assert_eq!(
851            dst_info.file.absolute_path, expected_abs,
852            "absolute_path should point to the destination file after move_to"
853        );
854        assert!(
855            dst_info.file.absolute_path.exists(),
856            "absolute_path should point to an existing file"
857        );
858    }
859
860    #[test]
861    fn neighbor_status_change_page_finds_next_and_previous_boundaries() {
862        let dir = tempfile::tempdir().expect("failed to create temp dir");
863        let db = Database::new(":memory:").expect("failed to create in-memory database");
864        db.migrate().expect("failed to run migrations");
865
866        let lib =
867            Library::new(dir.path(), &db, "Status Library").expect("failed to create library");
868
869        let statuses = [
870            SimpleStatus::New,
871            SimpleStatus::New,
872            SimpleStatus::Reading,
873            SimpleStatus::Reading,
874            SimpleStatus::Finished,
875            SimpleStatus::Finished,
876        ];
877
878        for (index, status) in statuses.into_iter().enumerate() {
879            let fp = Fp::from_str(&format!("{:016X}", index + 1)).expect("invalid fingerprint");
880            let info = make_status_info(
881                &format!("book-{}.pdf", index + 1),
882                &format!("Book {}", index + 1),
883                fp,
884                status,
885            );
886            lib.db
887                .insert_book(lib.library_id, fp, &info)
888                .expect("failed to insert book");
889        }
890
891        assert_eq!(
892            lib.neighbor_status_change_page(dir.path(), None, 0, 2, CycleDir::Next)
893                .expect("next boundary lookup failed"),
894            Some(1)
895        );
896        assert_eq!(
897            lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Previous)
898                .expect("previous boundary lookup failed"),
899            Some(1)
900        );
901        assert_eq!(
902            lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Next)
903                .expect("terminal next lookup failed"),
904            None
905        );
906        assert_eq!(
907            lib.neighbor_status_change_page(dir.path(), None, 0, 0, CycleDir::Next)
908                .expect("zero page size lookup failed"),
909            None
910        );
911    }
912
913    #[test]
914    fn next_book_after_returns_following_book_in_title_order() {
915        let dir = tempfile::tempdir().expect("failed to create temp dir");
916        let db = Database::new(":memory:").expect("failed to create in-memory database");
917        db.migrate().expect("failed to run migrations");
918
919        let mut lib =
920            Library::new(dir.path(), &db, "Next Book Library").expect("failed to create library");
921        lib.sort_method = SortMethod::Title;
922        lib.reverse_order = false;
923
924        let alpha_fp = Fp::from_str("0000000000000101").expect("invalid alpha fingerprint");
925        let beta_fp = Fp::from_str("0000000000000102").expect("invalid beta fingerprint");
926        let gamma_fp = Fp::from_str("0000000000000103").expect("invalid gamma fingerprint");
927
928        for (fp, title, path) in [
929            (beta_fp, "Beta", "beta.pdf"),
930            (gamma_fp, "Gamma", "gamma.pdf"),
931            (alpha_fp, "Alpha", "alpha.pdf"),
932        ] {
933            let info = make_info(path, title, fp);
934            lib.db
935                .insert_book(lib.library_id, fp, &info)
936                .expect("failed to insert book");
937        }
938
939        let next = lib
940            .next_book_after(alpha_fp)
941            .expect("alpha should have a next book");
942        assert_eq!(next.fp, Some(beta_fp));
943        assert_eq!(next.title, "Beta");
944
945        let last = lib.next_book_after(gamma_fp);
946        assert!(last.is_none(), "last book should not have a successor");
947
948        let missing = lib.next_book_after(
949            Fp::from_str("00000000000001FF").expect("invalid missing fingerprint"),
950        );
951        assert!(missing.is_none(), "missing fingerprint should return none");
952    }
953
954    #[test]
955    fn compute_sort_keys_assigns_correct_title_ranks() {
956        let dir = tempfile::tempdir().expect("failed to create temp dir");
957        let db = Database::new(":memory:").expect("failed to create in-memory database");
958        db.migrate().expect("failed to run migrations");
959
960        let lib =
961            Library::new(dir.path(), &db, "Sort Keys Library").expect("failed to create library");
962
963        let fp_a = Fp::from_str("0000000000000301").expect("invalid fp");
964        let fp_b = Fp::from_str("0000000000000302").expect("invalid fp");
965        let fp_c = Fp::from_str("0000000000000303").expect("invalid fp");
966
967        // Insert in non-alphabetical order.
968        for (fp, title, path) in [
969            (fp_c, "Zebra", "zebra.pdf"),
970            (fp_a, "Apple", "apple.pdf"),
971            (fp_b, "Mango", "mango.pdf"),
972        ] {
973            lib.db
974                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
975                .expect("failed to insert book");
976        }
977
978        lib.db
979            .compute_sort_keys(lib.library_id)
980            .expect("compute_sort_keys failed");
981
982        // Verify title sort order via page_books (ascending = alphabetical).
983        let (books, total) = lib
984            .db
985            .page_books(
986                lib.library_id,
987                Path::new(""),
988                SortMethod::Title,
989                false,
990                10,
991                0,
992            )
993            .expect("page_books failed");
994
995        assert_eq!(total, 3);
996        assert_eq!(
997            books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
998            vec!["Apple", "Mango", "Zebra"]
999        );
1000    }
1001
1002    #[test]
1003    fn page_books_paginates_correctly() {
1004        let dir = tempfile::tempdir().expect("failed to create temp dir");
1005        let db = Database::new(":memory:").expect("failed to create in-memory database");
1006        db.migrate().expect("failed to run migrations");
1007
1008        let lib =
1009            Library::new(dir.path(), &db, "Pagination Library").expect("failed to create library");
1010
1011        for i in 1u8..=5 {
1012            let fp = Fp::from_str(&format!("{:016X}", i)).expect("invalid fingerprint");
1013            let title = format!("Book {:02}", i);
1014            let path = format!("book{i}.pdf");
1015            lib.db
1016                .insert_book(lib.library_id, fp, &make_info(&path, &title, fp))
1017                .expect("failed to insert book");
1018        }
1019
1020        lib.db
1021            .compute_sort_keys(lib.library_id)
1022            .expect("compute_sort_keys failed");
1023
1024        // Page 0 with size 2 should return the first 2 books (title order).
1025        let (page0, total) = lib
1026            .db
1027            .page_books(
1028                lib.library_id,
1029                Path::new(""),
1030                SortMethod::Title,
1031                false,
1032                2,
1033                0,
1034            )
1035            .expect("page_books page 0 failed");
1036        assert_eq!(total, 5);
1037        assert_eq!(page0.len(), 2);
1038        assert_eq!(page0[0].title, "Book 01");
1039        assert_eq!(page0[1].title, "Book 02");
1040
1041        // Page 1 with size 2.
1042        let (page1, _) = lib
1043            .db
1044            .page_books(
1045                lib.library_id,
1046                Path::new(""),
1047                SortMethod::Title,
1048                false,
1049                2,
1050                2,
1051            )
1052            .expect("page_books page 1 failed");
1053        assert_eq!(page1.len(), 2);
1054        assert_eq!(page1[0].title, "Book 03");
1055        assert_eq!(page1[1].title, "Book 04");
1056
1057        // Last page with size 2.
1058        let (page2, _) = lib
1059            .db
1060            .page_books(
1061                lib.library_id,
1062                Path::new(""),
1063                SortMethod::Title,
1064                false,
1065                2,
1066                4,
1067            )
1068            .expect("page_books page 2 failed");
1069        assert_eq!(page2.len(), 1);
1070        assert_eq!(page2[0].title, "Book 05");
1071    }
1072
1073    #[test]
1074    fn page_books_reverse_order_reverses_results() {
1075        let dir = tempfile::tempdir().expect("failed to create temp dir");
1076        let db = Database::new(":memory:").expect("failed to create in-memory database");
1077        db.migrate().expect("failed to run migrations");
1078
1079        let lib =
1080            Library::new(dir.path(), &db, "Reverse Library").expect("failed to create library");
1081
1082        for (fp_hex, title, path) in [
1083            ("0000000000000401", "Alpha", "alpha.pdf"),
1084            ("0000000000000402", "Beta", "beta.pdf"),
1085            ("0000000000000403", "Gamma", "gamma.pdf"),
1086        ] {
1087            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1088            lib.db
1089                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1090                .expect("failed to insert book");
1091        }
1092
1093        lib.db
1094            .compute_sort_keys(lib.library_id)
1095            .expect("compute_sort_keys failed");
1096
1097        let (books, _) = lib
1098            .db
1099            .page_books(
1100                lib.library_id,
1101                Path::new(""),
1102                SortMethod::Title,
1103                true,
1104                10,
1105                0,
1106            )
1107            .expect("page_books failed");
1108
1109        assert_eq!(
1110            books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
1111            vec!["Gamma", "Beta", "Alpha"]
1112        );
1113    }
1114
1115    #[test]
1116    fn page_method_uses_db_pagination_without_query() {
1117        let dir = tempfile::tempdir().expect("failed to create temp dir");
1118        let db = Database::new(":memory:").expect("failed to create in-memory database");
1119        db.migrate().expect("failed to run migrations");
1120
1121        let mut lib =
1122            Library::new(dir.path(), &db, "Page Method Library").expect("failed to create library");
1123        lib.sort_method = SortMethod::Title;
1124        lib.reverse_order = false;
1125
1126        for (fp_hex, title, path) in [
1127            ("0000000000000501", "Charlie", "c.pdf"),
1128            ("0000000000000502", "Alice", "a.pdf"),
1129            ("0000000000000503", "Bob", "b.pdf"),
1130        ] {
1131            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1132            lib.db
1133                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1134                .expect("failed to insert book");
1135        }
1136
1137        lib.db
1138            .compute_sort_keys(lib.library_id)
1139            .expect("compute_sort_keys failed");
1140
1141        let result = lib.page(dir.path(), None, 0, 2).expect("page failed");
1142
1143        assert_eq!(result.total_count, 3);
1144        assert_eq!(result.books.len(), 2);
1145        assert_eq!(result.books[0].title, "Alice");
1146        assert_eq!(result.books[1].title, "Bob");
1147    }
1148
1149    #[test]
1150    fn list_subdirectory_returns_correct_absolute_paths() {
1151        let dir = tempfile::tempdir().expect("failed to create temp dir");
1152        let db = Database::new(":memory:").expect("failed to create in-memory database");
1153        db.migrate().expect("failed to run migrations");
1154
1155        let lib =
1156            Library::new(dir.path(), &db, "Dir Nav Library").expect("failed to create library");
1157
1158        // Simulate a library with books nested two levels deep.
1159        for (fp_hex, path, title) in [
1160            (
1161                "0000000000001001",
1162                "fiction/fantasy/book1.pdf",
1163                "Fantasy One",
1164            ),
1165            ("0000000000001002", "fiction/scifi/book2.pdf", "SciFi One"),
1166            ("0000000000001003", "nonfiction/book3.pdf", "Nonfiction One"),
1167        ] {
1168            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1169            lib.db
1170                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1171                .expect("failed to insert book");
1172        }
1173
1174        // Listing at root should return top-level dirs as absolute paths.
1175        let (_, root_dirs) = lib.list(dir.path(), None, true);
1176        let root_dir_paths: Vec<_> = root_dirs.iter().collect();
1177        assert_eq!(root_dir_paths.len(), 2);
1178        assert!(root_dirs.contains(&dir.path().join("fiction")));
1179        assert!(root_dirs.contains(&dir.path().join("nonfiction")));
1180
1181        // Listing under "fiction" should return only the immediate subdirs,
1182        // not double-prefixed paths like /tmp/.../fiction/fiction/fantasy.
1183        let fiction_prefix = dir.path().join("fiction");
1184        let (_, fiction_dirs) = lib.list(&fiction_prefix, None, true);
1185        assert_eq!(
1186            fiction_dirs.len(),
1187            2,
1188            "expected exactly 2 subdirs under fiction"
1189        );
1190        assert!(
1191            fiction_dirs.contains(&fiction_prefix.join("fantasy")),
1192            "expected fiction/fantasy, got: {fiction_dirs:?}"
1193        );
1194        assert!(
1195            fiction_dirs.contains(&fiction_prefix.join("scifi")),
1196            "expected fiction/scifi, got: {fiction_dirs:?}"
1197        );
1198    }
1199
1200    #[test]
1201    fn page_books_status_sort_orders_finished_new_reading() {
1202        let dir = tempfile::tempdir().expect("failed to create temp dir");
1203        let db = Database::new(":memory:").expect("failed to create in-memory database");
1204        db.migrate().expect("failed to run migrations");
1205
1206        let lib =
1207            Library::new(dir.path(), &db, "Status Sort Library").expect("failed to create library");
1208
1209        let fp_new = Fp::from_str("0000000000000601").expect("invalid fp");
1210        let fp_reading = Fp::from_str("0000000000000602").expect("invalid fp");
1211        let fp_finished = Fp::from_str("0000000000000603").expect("invalid fp");
1212
1213        for (fp, status, path, title) in [
1214            (fp_new, SimpleStatus::New, "new.pdf", "New Book"),
1215            (
1216                fp_reading,
1217                SimpleStatus::Reading,
1218                "reading.pdf",
1219                "Reading Book",
1220            ),
1221            (
1222                fp_finished,
1223                SimpleStatus::Finished,
1224                "finished.pdf",
1225                "Finished Book",
1226            ),
1227        ] {
1228            lib.db
1229                .insert_book(
1230                    lib.library_id,
1231                    fp,
1232                    &make_status_info(path, title, fp, status),
1233                )
1234                .expect("failed to insert book");
1235        }
1236
1237        lib.db
1238            .compute_sort_keys(lib.library_id)
1239            .expect("compute_sort_keys failed");
1240
1241        let (books, total) = lib
1242            .db
1243            .page_books(
1244                lib.library_id,
1245                Path::new(""),
1246                SortMethod::Status,
1247                false,
1248                10,
1249                0,
1250            )
1251            .expect("page_books with Status sort failed");
1252
1253        assert_eq!(total, 3);
1254        // Status ASC: Finished(0) < New(1) < Reading(2).
1255        assert_eq!(books[0].title, "Finished Book");
1256        assert_eq!(books[1].title, "New Book");
1257        assert_eq!(books[2].title, "Reading Book");
1258    }
1259
1260    #[test]
1261    fn page_books_progress_sort_orders_by_completion() {
1262        let dir = tempfile::tempdir().expect("failed to create temp dir");
1263        let db = Database::new(":memory:").expect("failed to create in-memory database");
1264        db.migrate().expect("failed to run migrations");
1265
1266        let lib = Library::new(dir.path(), &db, "Progress Sort Library")
1267            .expect("failed to create library");
1268
1269        let fp_new = Fp::from_str("0000000000000701").expect("invalid fp");
1270        let fp_halfway = Fp::from_str("0000000000000702").expect("invalid fp");
1271        let fp_finished = Fp::from_str("0000000000000703").expect("invalid fp");
1272
1273        let mut info_halfway = make_info("halfway.pdf", "Halfway Book", fp_halfway);
1274        info_halfway.reader_info = Some(ReaderInfo {
1275            current_page: 5,
1276            pages_count: 10,
1277            finished: false,
1278            ..Default::default()
1279        });
1280        info_halfway.reader = info_halfway.reader_info.clone();
1281
1282        for (fp, info) in [
1283            (
1284                fp_new,
1285                make_status_info("new.pdf", "New Book", fp_new, SimpleStatus::New),
1286            ),
1287            (fp_halfway, info_halfway),
1288            (
1289                fp_finished,
1290                make_status_info(
1291                    "finished.pdf",
1292                    "Finished Book",
1293                    fp_finished,
1294                    SimpleStatus::Finished,
1295                ),
1296            ),
1297        ] {
1298            lib.db
1299                .insert_book(lib.library_id, fp, &info)
1300                .expect("failed to insert book");
1301        }
1302
1303        lib.db
1304            .compute_sort_keys(lib.library_id)
1305            .expect("compute_sort_keys failed");
1306
1307        let (books, total) = lib
1308            .db
1309            .page_books(
1310                lib.library_id,
1311                Path::new(""),
1312                SortMethod::Progress,
1313                false,
1314                10,
1315                0,
1316            )
1317            .expect("page_books with Progress sort failed");
1318
1319        assert_eq!(total, 3);
1320        // Progress ASC: Finished(0) < New(1) < Reading-with-progress(2).
1321        assert_eq!(books[0].title, "Finished Book");
1322        assert_eq!(books[1].title, "New Book");
1323        assert_eq!(books[2].title, "Halfway Book");
1324    }
1325
1326    #[test]
1327    fn page_books_pages_sort_orders_by_page_count() {
1328        let dir = tempfile::tempdir().expect("failed to create temp dir");
1329        let db = Database::new(":memory:").expect("failed to create in-memory database");
1330        db.migrate().expect("failed to run migrations");
1331
1332        let lib =
1333            Library::new(dir.path(), &db, "Pages Sort Library").expect("failed to create library");
1334
1335        for (fp_hex, path, title, pages) in [
1336            ("0000000000000801", "big.pdf", "Big Book", 500usize),
1337            ("0000000000000802", "tiny.pdf", "Tiny Book", 50),
1338            ("0000000000000803", "medium.pdf", "Medium Book", 200),
1339        ] {
1340            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1341            let mut info = make_info(path, title, fp);
1342            info.reader_info = Some(ReaderInfo {
1343                pages_count: pages,
1344                ..Default::default()
1345            });
1346            info.reader = info.reader_info.clone();
1347            lib.db
1348                .insert_book(lib.library_id, fp, &info)
1349                .expect("failed to insert book");
1350        }
1351
1352        lib.db
1353            .compute_sort_keys(lib.library_id)
1354            .expect("compute_sort_keys failed");
1355
1356        let (books, total) = lib
1357            .db
1358            .page_books(
1359                lib.library_id,
1360                Path::new(""),
1361                SortMethod::Pages,
1362                false,
1363                10,
1364                0,
1365            )
1366            .expect("page_books with Pages sort failed");
1367
1368        assert_eq!(total, 3);
1369        assert_eq!(books[0].title, "Tiny Book");
1370        assert_eq!(books[1].title, "Medium Book");
1371        assert_eq!(books[2].title, "Big Book");
1372    }
1373
1374    #[test]
1375    fn page_books_size_sort_orders_by_file_size() {
1376        let dir = tempfile::tempdir().expect("failed to create temp dir");
1377        let db = Database::new(":memory:").expect("failed to create in-memory database");
1378        db.migrate().expect("failed to run migrations");
1379
1380        let lib =
1381            Library::new(dir.path(), &db, "Size Sort Library").expect("failed to create library");
1382
1383        for (fp_hex, path, title, size) in [
1384            ("0000000000000901", "big.pdf", "Big Book", 9000u64),
1385            ("0000000000000902", "tiny.pdf", "Tiny Book", 100),
1386            ("0000000000000903", "medium.pdf", "Medium Book", 4500),
1387        ] {
1388            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1389            let mut info = make_info(path, title, fp);
1390            info.file.size = size;
1391            lib.db
1392                .insert_book(lib.library_id, fp, &info)
1393                .expect("failed to insert book");
1394        }
1395
1396        lib.db
1397            .compute_sort_keys(lib.library_id)
1398            .expect("compute_sort_keys failed");
1399
1400        let (books, total) = lib
1401            .db
1402            .page_books(
1403                lib.library_id,
1404                Path::new(""),
1405                SortMethod::Size,
1406                false,
1407                10,
1408                0,
1409            )
1410            .expect("page_books with Size sort failed");
1411
1412        assert_eq!(total, 3);
1413        assert_eq!(books[0].title, "Tiny Book");
1414        assert_eq!(books[1].title, "Medium Book");
1415        assert_eq!(books[2].title, "Big Book");
1416    }
1417
1418    #[test]
1419    fn page_books_kind_sort_orders_alphabetically_by_file_kind() {
1420        let dir = tempfile::tempdir().expect("failed to create temp dir");
1421        let db = Database::new(":memory:").expect("failed to create in-memory database");
1422        db.migrate().expect("failed to run migrations");
1423
1424        let lib =
1425            Library::new(dir.path(), &db, "Kind Sort Library").expect("failed to create library");
1426
1427        for (fp_hex, path, title, kind) in [
1428            ("0000000000000A01", "book.pdf", "PDF Book", "pdf"),
1429            ("0000000000000A02", "book.epub", "EPUB Book", "epub"),
1430            ("0000000000000A03", "book.cbz", "CBZ Book", "cbz"),
1431        ] {
1432            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1433            let mut info = make_info(path, title, fp);
1434            info.file.kind = kind.to_string();
1435            lib.db
1436                .insert_book(lib.library_id, fp, &info)
1437                .expect("failed to insert book");
1438        }
1439
1440        lib.db
1441            .compute_sort_keys(lib.library_id)
1442            .expect("compute_sort_keys failed");
1443
1444        let (books, total) = lib
1445            .db
1446            .page_books(
1447                lib.library_id,
1448                Path::new(""),
1449                SortMethod::Kind,
1450                false,
1451                10,
1452                0,
1453            )
1454            .expect("page_books with Kind sort failed");
1455
1456        assert_eq!(total, 3);
1457        // Alphabetical: cbz < epub < pdf.
1458        assert_eq!(books[0].title, "CBZ Book");
1459        assert_eq!(books[1].title, "EPUB Book");
1460        assert_eq!(books[2].title, "PDF Book");
1461    }
1462
1463    #[test]
1464    fn page_books_added_sort_orders_by_insertion_time() {
1465        use chrono::NaiveDateTime;
1466        let dir = tempfile::tempdir().expect("failed to create temp dir");
1467        let db = Database::new(":memory:").expect("failed to create in-memory database");
1468        db.migrate().expect("failed to run migrations");
1469
1470        let lib =
1471            Library::new(dir.path(), &db, "Added Sort Library").expect("failed to create library");
1472
1473        let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1474            .expect("invalid datetime");
1475        let t1 = NaiveDateTime::parse_from_str("2021-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
1476            .expect("invalid datetime");
1477        let t2 = NaiveDateTime::parse_from_str("2023-03-20 08:30:00", "%Y-%m-%d %H:%M:%S")
1478            .expect("invalid datetime");
1479
1480        for (fp_hex, path, title, added) in [
1481            ("0000000000000B01", "old.pdf", "Old Book", t0),
1482            ("0000000000000B02", "recent.pdf", "Recent Book", t2),
1483            ("0000000000000B03", "mid.pdf", "Middle Book", t1),
1484        ] {
1485            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1486            let mut info = make_info(path, title, fp);
1487            info.added = added;
1488            lib.db
1489                .insert_book(lib.library_id, fp, &info)
1490                .expect("failed to insert book");
1491        }
1492
1493        lib.db
1494            .compute_sort_keys(lib.library_id)
1495            .expect("compute_sort_keys failed");
1496
1497        let (books, total) = lib
1498            .db
1499            .page_books(
1500                lib.library_id,
1501                Path::new(""),
1502                SortMethod::Added,
1503                false,
1504                10,
1505                0,
1506            )
1507            .expect("page_books with Added sort failed");
1508
1509        assert_eq!(total, 3);
1510        assert_eq!(books[0].title, "Old Book");
1511        assert_eq!(books[1].title, "Middle Book");
1512        assert_eq!(books[2].title, "Recent Book");
1513    }
1514
1515    #[test]
1516    fn page_books_opened_sort_orders_by_last_opened_time() {
1517        use chrono::NaiveDateTime;
1518        let dir = tempfile::tempdir().expect("failed to create temp dir");
1519        let db = Database::new(":memory:").expect("failed to create in-memory database");
1520        db.migrate().expect("failed to run migrations");
1521
1522        let lib =
1523            Library::new(dir.path(), &db, "Opened Sort Library").expect("failed to create library");
1524
1525        let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1526            .expect("invalid datetime");
1527        let t1 = NaiveDateTime::parse_from_str("2022-09-10 09:00:00", "%Y-%m-%d %H:%M:%S")
1528            .expect("invalid datetime");
1529        let t2 = NaiveDateTime::parse_from_str("2024-04-01 17:45:00", "%Y-%m-%d %H:%M:%S")
1530            .expect("invalid datetime");
1531
1532        for (fp_hex, path, title, opened) in [
1533            ("0000000000000C01", "oldest.pdf", "Oldest Opened", t0),
1534            ("0000000000000C02", "newest.pdf", "Newest Opened", t2),
1535            ("0000000000000C03", "middle.pdf", "Middle Opened", t1),
1536        ] {
1537            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1538            let mut info = make_info(path, title, fp);
1539            info.reader_info = Some(ReaderInfo {
1540                opened,
1541                pages_count: 100,
1542                ..Default::default()
1543            });
1544            info.reader = info.reader_info.clone();
1545            lib.db
1546                .insert_book(lib.library_id, fp, &info)
1547                .expect("failed to insert book");
1548        }
1549
1550        lib.db
1551            .compute_sort_keys(lib.library_id)
1552            .expect("compute_sort_keys failed");
1553
1554        let (books, total) = lib
1555            .db
1556            .page_books(
1557                lib.library_id,
1558                Path::new(""),
1559                SortMethod::Opened,
1560                false,
1561                10,
1562                0,
1563            )
1564            .expect("page_books with Opened sort failed");
1565
1566        assert_eq!(total, 3);
1567        assert_eq!(books[0].title, "Oldest Opened");
1568        assert_eq!(books[1].title, "Middle Opened");
1569        assert_eq!(books[2].title, "Newest Opened");
1570    }
1571
1572    #[test]
1573    fn page_books_year_sort_orders_by_publication_year() {
1574        let dir = tempfile::tempdir().expect("failed to create temp dir");
1575        let db = Database::new(":memory:").expect("failed to create in-memory database");
1576        db.migrate().expect("failed to run migrations");
1577
1578        let lib =
1579            Library::new(dir.path(), &db, "Year Sort Library").expect("failed to create library");
1580
1581        for (fp_hex, path, title, year) in [
1582            ("0000000000000D01", "modern.pdf", "Modern Book", "2020"),
1583            ("0000000000000D02", "old.pdf", "Old Book", "1990"),
1584            ("0000000000000D03", "ancient.pdf", "Ancient Book", "1850"),
1585        ] {
1586            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1587            let mut info = make_info(path, title, fp);
1588            info.year = year.to_string();
1589            lib.db
1590                .insert_book(lib.library_id, fp, &info)
1591                .expect("failed to insert book");
1592        }
1593
1594        lib.db
1595            .compute_sort_keys(lib.library_id)
1596            .expect("compute_sort_keys failed");
1597
1598        let (books, total) = lib
1599            .db
1600            .page_books(
1601                lib.library_id,
1602                Path::new(""),
1603                SortMethod::Year,
1604                false,
1605                10,
1606                0,
1607            )
1608            .expect("page_books with Year sort failed");
1609
1610        assert_eq!(total, 3);
1611        assert_eq!(books[0].title, "Ancient Book");
1612        assert_eq!(books[1].title, "Old Book");
1613        assert_eq!(books[2].title, "Modern Book");
1614    }
1615
1616    #[test]
1617    fn page_books_count_query_respects_prefix_filter() {
1618        let dir = tempfile::tempdir().expect("failed to create temp dir");
1619        let db = Database::new(":memory:").expect("failed to create in-memory database");
1620        db.migrate().expect("failed to run migrations");
1621
1622        let lib = Library::new(dir.path(), &db, "Prefix Count Library")
1623            .expect("failed to create library");
1624
1625        for (fp_hex, path, title) in [
1626            ("0000000000000E01", "fiction/book1.pdf", "Fiction One"),
1627            ("0000000000000E02", "fiction/book2.pdf", "Fiction Two"),
1628            ("0000000000000E03", "nonfiction/book3.pdf", "Nonfiction One"),
1629        ] {
1630            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1631            lib.db
1632                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1633                .expect("failed to insert book");
1634        }
1635
1636        lib.db
1637            .compute_sort_keys(lib.library_id)
1638            .expect("compute_sort_keys failed");
1639
1640        let (books, total) = lib
1641            .db
1642            .page_books(
1643                lib.library_id,
1644                Path::new("fiction"),
1645                SortMethod::Title,
1646                false,
1647                10,
1648                0,
1649            )
1650            .expect("page_books with prefix filter failed");
1651
1652        assert_eq!(total, 2, "count query should reflect the prefix filter");
1653        assert_eq!(books.len(), 2);
1654        assert!(books.iter().all(|b| b.title.starts_with("Fiction")));
1655    }
1656
1657    #[test]
1658    fn resolve_fingerprint_prefers_db_and_falls_back_to_filesystem() {
1659        let dir = tempfile::tempdir().expect("failed to create temp dir");
1660        let db = Database::new(":memory:").expect("failed to create in-memory database");
1661        db.migrate().expect("failed to run migrations");
1662
1663        let lib =
1664            Library::new(dir.path(), &db, "Fingerprint Library").expect("failed to create library");
1665
1666        let stored_fp = Fp::from_str("0000000000000201").expect("invalid stored fingerprint");
1667        let stored_info = make_info("stored.pdf", "Stored", stored_fp);
1668        lib.db
1669            .insert_book(lib.library_id, stored_fp, &stored_info)
1670            .expect("failed to insert stored book");
1671
1672        assert_eq!(
1673            lib.resolve_fingerprint(Path::new("stored.pdf")),
1674            Some(stored_fp)
1675        );
1676
1677        let fallback_path = dir.path().join("fallback.pdf");
1678        fs::write(&fallback_path, b"fallback content").expect("failed to write fallback file");
1679        let expected_fallback_fp = fallback_path
1680            .fingerprint()
1681            .expect("failed to fingerprint fallback file");
1682
1683        assert_eq!(
1684            lib.resolve_fingerprint(Path::new("fallback.pdf")),
1685            Some(expected_fallback_fp)
1686        );
1687        assert_eq!(lib.resolve_fingerprint(Path::new("missing.pdf")), None);
1688    }
1689}