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::{Error, bail, format_err};
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    pub fn is_empty(&self) -> Option<bool> {
616        self.db
617            .count_books(self.library_id)
618            .ok()
619            .map(|count| count == 0)
620    }
621
622    pub fn next_book_after(&self, fp: Fp) -> Option<Info> {
623        let mut books: Vec<Info> = self
624            .db
625            .list_books_under_prefix(self.library_id, Path::new(""))
626            .ok()?;
627
628        if books.is_empty() {
629            return None;
630        }
631
632        books.sort_by(|left, right| {
633            let ordering = sorter(self.sort_method)(left, right);
634            if self.reverse_order {
635                ordering.reverse()
636            } else {
637                ordering
638            }
639        });
640
641        let current_index = books
642            .iter()
643            .position(|candidate| candidate.fp == Some(fp))?;
644        books.into_iter().nth(current_index + 1)
645    }
646
647    pub fn most_recently_opened_reading_book(&self) -> Option<Info> {
648        self.db
649            .most_recently_opened_reading_book(self.library_id)
650            .map_err(|e| {
651                error!(error = %e, library_id = self.library_id, "failed to get most recently opened reading book");
652            })
653            .ok()
654            .flatten()
655    }
656
657    fn resolve_fingerprint(&self, path: &Path) -> Option<Fp> {
658        match self.db.get_book_by_path(self.library_id, path) {
659            Ok(Some(info)) => {
660                if let Some(fp) = info.fp {
661                    return Some(fp);
662                }
663            }
664            Ok(None) => {}
665            Err(e) => {
666                error!(
667                    path = %path.display(),
668                    error = %e,
669                    "failed to resolve fingerprint from database"
670                );
671            }
672        }
673
674        let full_path = self.home.join(path);
675
676        match full_path.fingerprint() {
677            Ok(fp) => Some(fp),
678            Err(e) => {
679                error!(path = %full_path.display(), error = %e, "failed to fingerprint path");
680                None
681            }
682        }
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crate::db::Database;
690    use crate::geom::CycleDir;
691    use crate::metadata::FileInfo;
692    use crate::settings::ImportSettings;
693    use crate::task::ShutdownSignal;
694    use std::str::FromStr;
695    use std::sync::mpsc;
696
697    fn setup_library_with_book(
698        dir: &Path,
699        db: &Database,
700        name: &str,
701        filename: &str,
702    ) -> (Library, PathBuf) {
703        let lib = Library::new(dir, db, name).expect("failed to create library");
704        fs::write(dir.join(filename), b"dummy book content").expect("failed to write test file");
705        let (tx, _rx) = mpsc::channel();
706        let notif_id = crate::view::ViewId::MessageNotif(0);
707        let shutdown = ShutdownSignal::never();
708        importer::run(
709            &lib.db,
710            lib.library_id,
711            dir,
712            &ImportSettings::default(),
713            false,
714            &tx,
715            notif_id,
716            &shutdown,
717        );
718        (lib, PathBuf::from(filename))
719    }
720
721    fn make_info(path: &str, title: &str, fp: Fp) -> Info {
722        Info {
723            title: title.to_string(),
724            file: FileInfo {
725                path: PathBuf::from(path),
726                absolute_path: PathBuf::from(format!("/library/{path}")),
727                kind: "pdf".to_string(),
728                size: 1024,
729                mtime: None,
730            },
731            fp: Some(fp),
732            ..Default::default()
733        }
734    }
735
736    fn make_status_info(path: &str, title: &str, fp: Fp, status: SimpleStatus) -> Info {
737        let mut info = make_info(path, title, fp);
738        let reader = match status {
739            SimpleStatus::New => None,
740            SimpleStatus::Reading => Some(ReaderInfo {
741                current_page: 1,
742                pages_count: 10,
743                finished: false,
744                ..Default::default()
745            }),
746            SimpleStatus::Finished => Some(ReaderInfo {
747                current_page: 10,
748                pages_count: 10,
749                finished: true,
750                ..Default::default()
751            }),
752        };
753        info.reader = reader.clone();
754        info.reader_info = reader;
755        info
756    }
757
758    #[test]
759    fn copy_to_sets_absolute_path_in_destination() {
760        let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
761        let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
762        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
763        db.init(0).expect("failed to run migrations");
764
765        let (mut src_lib, rel_path) =
766            setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
767        let mut dst_lib =
768            Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
769
770        let src_books = src_lib
771            .db
772            .get_all_books(src_lib.library_id)
773            .expect("failed to get src books");
774        assert!(
775            !src_books.is_empty(),
776            "source library should contain the book"
777        );
778
779        src_lib
780            .copy_to(&rel_path, &mut dst_lib)
781            .expect("copy_to failed");
782
783        let dst_books = dst_lib
784            .db
785            .get_all_books(dst_lib.library_id)
786            .expect("failed to get dst books");
787
788        let dst_info = dst_books
789            .into_iter()
790            .next()
791            .expect("destination library should contain the copied book");
792
793        let expected_abs = dst_dir.path().join(&dst_info.file.path);
794        assert_eq!(
795            dst_info.file.absolute_path, expected_abs,
796            "absolute_path should point to the destination file after copy_to"
797        );
798        assert!(
799            dst_info.file.absolute_path.exists(),
800            "absolute_path should point to an existing file"
801        );
802    }
803
804    #[test]
805    fn move_to_sets_absolute_path_in_destination() {
806        let src_dir = tempfile::tempdir().expect("failed to create src temp dir");
807        let dst_dir = tempfile::tempdir().expect("failed to create dst temp dir");
808        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
809        db.init(0).expect("failed to run migrations");
810
811        let (mut src_lib, rel_path) =
812            setup_library_with_book(src_dir.path(), &db, "Source", "book.epub");
813        let mut dst_lib =
814            Library::new(dst_dir.path(), &db, "Destination").expect("failed to create dst lib");
815
816        let src_books = src_lib
817            .db
818            .get_all_books(src_lib.library_id)
819            .expect("failed to get src books");
820        assert!(
821            !src_books.is_empty(),
822            "source library should contain the book"
823        );
824
825        src_lib
826            .move_to(&rel_path, &mut dst_lib)
827            .expect("move_to failed");
828
829        let src_books_after = src_lib
830            .db
831            .get_all_books(src_lib.library_id)
832            .expect("failed to get src books after move");
833        assert!(
834            src_books_after.is_empty(),
835            "source library should no longer contain the book after move"
836        );
837
838        let dst_books = dst_lib
839            .db
840            .get_all_books(dst_lib.library_id)
841            .expect("failed to get dst books");
842
843        let dst_info = dst_books
844            .into_iter()
845            .next()
846            .expect("destination library should contain the moved book");
847
848        let expected_abs = dst_dir.path().join(&dst_info.file.path);
849        assert_eq!(
850            dst_info.file.absolute_path, expected_abs,
851            "absolute_path should point to the destination file after move_to"
852        );
853        assert!(
854            dst_info.file.absolute_path.exists(),
855            "absolute_path should point to an existing file"
856        );
857    }
858
859    #[test]
860    fn neighbor_status_change_page_finds_next_and_previous_boundaries() {
861        let dir = tempfile::tempdir().expect("failed to create temp dir");
862        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
863        db.init(0).expect("failed to run migrations");
864
865        let lib =
866            Library::new(dir.path(), &db, "Status Library").expect("failed to create library");
867
868        let statuses = [
869            SimpleStatus::New,
870            SimpleStatus::New,
871            SimpleStatus::Reading,
872            SimpleStatus::Reading,
873            SimpleStatus::Finished,
874            SimpleStatus::Finished,
875        ];
876
877        for (index, status) in statuses.into_iter().enumerate() {
878            let fp = Fp::from_str(&format!("{:016X}", index + 1)).expect("invalid fingerprint");
879            let info = make_status_info(
880                &format!("book-{}.pdf", index + 1),
881                &format!("Book {}", index + 1),
882                fp,
883                status,
884            );
885            lib.db
886                .insert_book(lib.library_id, fp, &info)
887                .expect("failed to insert book");
888        }
889
890        assert_eq!(
891            lib.neighbor_status_change_page(dir.path(), None, 0, 2, CycleDir::Next)
892                .expect("next boundary lookup failed"),
893            Some(1)
894        );
895        assert_eq!(
896            lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Previous)
897                .expect("previous boundary lookup failed"),
898            Some(1)
899        );
900        assert_eq!(
901            lib.neighbor_status_change_page(dir.path(), None, 2, 2, CycleDir::Next)
902                .expect("terminal next lookup failed"),
903            None
904        );
905        assert_eq!(
906            lib.neighbor_status_change_page(dir.path(), None, 0, 0, CycleDir::Next)
907                .expect("zero page size lookup failed"),
908            None
909        );
910    }
911
912    #[test]
913    fn next_book_after_returns_following_book_in_title_order() {
914        let dir = tempfile::tempdir().expect("failed to create temp dir");
915        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
916        db.init(0).expect("failed to run migrations");
917
918        let mut lib =
919            Library::new(dir.path(), &db, "Next Book Library").expect("failed to create library");
920        lib.sort_method = SortMethod::Title;
921        lib.reverse_order = false;
922
923        let alpha_fp = Fp::from_str("0000000000000101").expect("invalid alpha fingerprint");
924        let beta_fp = Fp::from_str("0000000000000102").expect("invalid beta fingerprint");
925        let gamma_fp = Fp::from_str("0000000000000103").expect("invalid gamma fingerprint");
926
927        for (fp, title, path) in [
928            (beta_fp, "Beta", "beta.pdf"),
929            (gamma_fp, "Gamma", "gamma.pdf"),
930            (alpha_fp, "Alpha", "alpha.pdf"),
931        ] {
932            let info = make_info(path, title, fp);
933            lib.db
934                .insert_book(lib.library_id, fp, &info)
935                .expect("failed to insert book");
936        }
937
938        let next = lib
939            .next_book_after(alpha_fp)
940            .expect("alpha should have a next book");
941        assert_eq!(next.fp, Some(beta_fp));
942        assert_eq!(next.title, "Beta");
943
944        let last = lib.next_book_after(gamma_fp);
945        assert!(last.is_none(), "last book should not have a successor");
946
947        let missing = lib.next_book_after(
948            Fp::from_str("00000000000001FF").expect("invalid missing fingerprint"),
949        );
950        assert!(missing.is_none(), "missing fingerprint should return none");
951    }
952
953    #[test]
954    fn compute_sort_keys_assigns_correct_title_ranks() {
955        let dir = tempfile::tempdir().expect("failed to create temp dir");
956        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
957        db.init(0).expect("failed to run migrations");
958
959        let lib =
960            Library::new(dir.path(), &db, "Sort Keys Library").expect("failed to create library");
961
962        let fp_a = Fp::from_str("0000000000000301").expect("invalid fp");
963        let fp_b = Fp::from_str("0000000000000302").expect("invalid fp");
964        let fp_c = Fp::from_str("0000000000000303").expect("invalid fp");
965
966        // Insert in non-alphabetical order.
967        for (fp, title, path) in [
968            (fp_c, "Zebra", "zebra.pdf"),
969            (fp_a, "Apple", "apple.pdf"),
970            (fp_b, "Mango", "mango.pdf"),
971        ] {
972            lib.db
973                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
974                .expect("failed to insert book");
975        }
976
977        lib.db
978            .compute_sort_keys(lib.library_id)
979            .expect("compute_sort_keys failed");
980
981        // Verify title sort order via page_books (ascending = alphabetical).
982        let (books, total) = lib
983            .db
984            .page_books(
985                lib.library_id,
986                Path::new(""),
987                SortMethod::Title,
988                false,
989                10,
990                0,
991            )
992            .expect("page_books failed");
993
994        assert_eq!(total, 3);
995        assert_eq!(
996            books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
997            vec!["Apple", "Mango", "Zebra"]
998        );
999    }
1000
1001    #[test]
1002    fn page_books_paginates_correctly() {
1003        let dir = tempfile::tempdir().expect("failed to create temp dir");
1004        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1005        db.init(0).expect("failed to run migrations");
1006
1007        let lib =
1008            Library::new(dir.path(), &db, "Pagination Library").expect("failed to create library");
1009
1010        for i in 1u8..=5 {
1011            let fp = Fp::from_str(&format!("{:016X}", i)).expect("invalid fingerprint");
1012            let title = format!("Book {:02}", i);
1013            let path = format!("book{i}.pdf");
1014            lib.db
1015                .insert_book(lib.library_id, fp, &make_info(&path, &title, fp))
1016                .expect("failed to insert book");
1017        }
1018
1019        lib.db
1020            .compute_sort_keys(lib.library_id)
1021            .expect("compute_sort_keys failed");
1022
1023        // Page 0 with size 2 should return the first 2 books (title order).
1024        let (page0, total) = lib
1025            .db
1026            .page_books(
1027                lib.library_id,
1028                Path::new(""),
1029                SortMethod::Title,
1030                false,
1031                2,
1032                0,
1033            )
1034            .expect("page_books page 0 failed");
1035        assert_eq!(total, 5);
1036        assert_eq!(page0.len(), 2);
1037        assert_eq!(page0[0].title, "Book 01");
1038        assert_eq!(page0[1].title, "Book 02");
1039
1040        // Page 1 with size 2.
1041        let (page1, _) = lib
1042            .db
1043            .page_books(
1044                lib.library_id,
1045                Path::new(""),
1046                SortMethod::Title,
1047                false,
1048                2,
1049                2,
1050            )
1051            .expect("page_books page 1 failed");
1052        assert_eq!(page1.len(), 2);
1053        assert_eq!(page1[0].title, "Book 03");
1054        assert_eq!(page1[1].title, "Book 04");
1055
1056        // Last page with size 2.
1057        let (page2, _) = lib
1058            .db
1059            .page_books(
1060                lib.library_id,
1061                Path::new(""),
1062                SortMethod::Title,
1063                false,
1064                2,
1065                4,
1066            )
1067            .expect("page_books page 2 failed");
1068        assert_eq!(page2.len(), 1);
1069        assert_eq!(page2[0].title, "Book 05");
1070    }
1071
1072    #[test]
1073    fn page_books_reverse_order_reverses_results() {
1074        let dir = tempfile::tempdir().expect("failed to create temp dir");
1075        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1076        db.init(0).expect("failed to run migrations");
1077
1078        let lib =
1079            Library::new(dir.path(), &db, "Reverse Library").expect("failed to create library");
1080
1081        for (fp_hex, title, path) in [
1082            ("0000000000000401", "Alpha", "alpha.pdf"),
1083            ("0000000000000402", "Beta", "beta.pdf"),
1084            ("0000000000000403", "Gamma", "gamma.pdf"),
1085        ] {
1086            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1087            lib.db
1088                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1089                .expect("failed to insert book");
1090        }
1091
1092        lib.db
1093            .compute_sort_keys(lib.library_id)
1094            .expect("compute_sort_keys failed");
1095
1096        let (books, _) = lib
1097            .db
1098            .page_books(
1099                lib.library_id,
1100                Path::new(""),
1101                SortMethod::Title,
1102                true,
1103                10,
1104                0,
1105            )
1106            .expect("page_books failed");
1107
1108        assert_eq!(
1109            books.iter().map(|b| b.title.as_str()).collect::<Vec<_>>(),
1110            vec!["Gamma", "Beta", "Alpha"]
1111        );
1112    }
1113
1114    #[test]
1115    fn page_method_uses_db_pagination_without_query() {
1116        let dir = tempfile::tempdir().expect("failed to create temp dir");
1117        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1118        db.init(0).expect("failed to run migrations");
1119
1120        let mut lib =
1121            Library::new(dir.path(), &db, "Page Method Library").expect("failed to create library");
1122        lib.sort_method = SortMethod::Title;
1123        lib.reverse_order = false;
1124
1125        for (fp_hex, title, path) in [
1126            ("0000000000000501", "Charlie", "c.pdf"),
1127            ("0000000000000502", "Alice", "a.pdf"),
1128            ("0000000000000503", "Bob", "b.pdf"),
1129        ] {
1130            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1131            lib.db
1132                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1133                .expect("failed to insert book");
1134        }
1135
1136        lib.db
1137            .compute_sort_keys(lib.library_id)
1138            .expect("compute_sort_keys failed");
1139
1140        let result = lib.page(dir.path(), None, 0, 2).expect("page failed");
1141
1142        assert_eq!(result.total_count, 3);
1143        assert_eq!(result.books.len(), 2);
1144        assert_eq!(result.books[0].title, "Alice");
1145        assert_eq!(result.books[1].title, "Bob");
1146    }
1147
1148    #[test]
1149    fn list_subdirectory_returns_correct_absolute_paths() {
1150        let dir = tempfile::tempdir().expect("failed to create temp dir");
1151        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1152        db.init(0).expect("failed to run migrations");
1153
1154        let lib =
1155            Library::new(dir.path(), &db, "Dir Nav Library").expect("failed to create library");
1156
1157        // Simulate a library with books nested two levels deep.
1158        for (fp_hex, path, title) in [
1159            (
1160                "0000000000001001",
1161                "fiction/fantasy/book1.pdf",
1162                "Fantasy One",
1163            ),
1164            ("0000000000001002", "fiction/scifi/book2.pdf", "SciFi One"),
1165            ("0000000000001003", "nonfiction/book3.pdf", "Nonfiction One"),
1166        ] {
1167            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1168            lib.db
1169                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1170                .expect("failed to insert book");
1171        }
1172
1173        // Listing at root should return top-level dirs as absolute paths.
1174        let (_, root_dirs) = lib.list(dir.path(), None, true);
1175        let root_dir_paths: Vec<_> = root_dirs.iter().collect();
1176        assert_eq!(root_dir_paths.len(), 2);
1177        assert!(root_dirs.contains(&dir.path().join("fiction")));
1178        assert!(root_dirs.contains(&dir.path().join("nonfiction")));
1179
1180        // Listing under "fiction" should return only the immediate subdirs,
1181        // not double-prefixed paths like /tmp/.../fiction/fiction/fantasy.
1182        let fiction_prefix = dir.path().join("fiction");
1183        let (_, fiction_dirs) = lib.list(&fiction_prefix, None, true);
1184        assert_eq!(
1185            fiction_dirs.len(),
1186            2,
1187            "expected exactly 2 subdirs under fiction"
1188        );
1189        assert!(
1190            fiction_dirs.contains(&fiction_prefix.join("fantasy")),
1191            "expected fiction/fantasy, got: {fiction_dirs:?}"
1192        );
1193        assert!(
1194            fiction_dirs.contains(&fiction_prefix.join("scifi")),
1195            "expected fiction/scifi, got: {fiction_dirs:?}"
1196        );
1197    }
1198
1199    #[test]
1200    fn page_books_status_sort_orders_finished_new_reading() {
1201        let dir = tempfile::tempdir().expect("failed to create temp dir");
1202        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1203        db.init(0).expect("failed to run migrations");
1204
1205        let lib =
1206            Library::new(dir.path(), &db, "Status Sort Library").expect("failed to create library");
1207
1208        let fp_new = Fp::from_str("0000000000000601").expect("invalid fp");
1209        let fp_reading = Fp::from_str("0000000000000602").expect("invalid fp");
1210        let fp_finished = Fp::from_str("0000000000000603").expect("invalid fp");
1211
1212        for (fp, status, path, title) in [
1213            (fp_new, SimpleStatus::New, "new.pdf", "New Book"),
1214            (
1215                fp_reading,
1216                SimpleStatus::Reading,
1217                "reading.pdf",
1218                "Reading Book",
1219            ),
1220            (
1221                fp_finished,
1222                SimpleStatus::Finished,
1223                "finished.pdf",
1224                "Finished Book",
1225            ),
1226        ] {
1227            lib.db
1228                .insert_book(
1229                    lib.library_id,
1230                    fp,
1231                    &make_status_info(path, title, fp, status),
1232                )
1233                .expect("failed to insert book");
1234        }
1235
1236        lib.db
1237            .compute_sort_keys(lib.library_id)
1238            .expect("compute_sort_keys failed");
1239
1240        let (books, total) = lib
1241            .db
1242            .page_books(
1243                lib.library_id,
1244                Path::new(""),
1245                SortMethod::Status,
1246                false,
1247                10,
1248                0,
1249            )
1250            .expect("page_books with Status sort failed");
1251
1252        assert_eq!(total, 3);
1253        // Status ASC: Finished(0) < New(1) < Reading(2).
1254        assert_eq!(books[0].title, "Finished Book");
1255        assert_eq!(books[1].title, "New Book");
1256        assert_eq!(books[2].title, "Reading Book");
1257    }
1258
1259    #[test]
1260    fn page_books_progress_sort_orders_by_completion() {
1261        let dir = tempfile::tempdir().expect("failed to create temp dir");
1262        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1263        db.init(0).expect("failed to run migrations");
1264
1265        let lib = Library::new(dir.path(), &db, "Progress Sort Library")
1266            .expect("failed to create library");
1267
1268        let fp_new = Fp::from_str("0000000000000701").expect("invalid fp");
1269        let fp_halfway = Fp::from_str("0000000000000702").expect("invalid fp");
1270        let fp_finished = Fp::from_str("0000000000000703").expect("invalid fp");
1271
1272        let mut info_halfway = make_info("halfway.pdf", "Halfway Book", fp_halfway);
1273        info_halfway.reader_info = Some(ReaderInfo {
1274            current_page: 5,
1275            pages_count: 10,
1276            finished: false,
1277            ..Default::default()
1278        });
1279        info_halfway.reader = info_halfway.reader_info.clone();
1280
1281        for (fp, info) in [
1282            (
1283                fp_new,
1284                make_status_info("new.pdf", "New Book", fp_new, SimpleStatus::New),
1285            ),
1286            (fp_halfway, info_halfway),
1287            (
1288                fp_finished,
1289                make_status_info(
1290                    "finished.pdf",
1291                    "Finished Book",
1292                    fp_finished,
1293                    SimpleStatus::Finished,
1294                ),
1295            ),
1296        ] {
1297            lib.db
1298                .insert_book(lib.library_id, fp, &info)
1299                .expect("failed to insert book");
1300        }
1301
1302        lib.db
1303            .compute_sort_keys(lib.library_id)
1304            .expect("compute_sort_keys failed");
1305
1306        let (books, total) = lib
1307            .db
1308            .page_books(
1309                lib.library_id,
1310                Path::new(""),
1311                SortMethod::Progress,
1312                false,
1313                10,
1314                0,
1315            )
1316            .expect("page_books with Progress sort failed");
1317
1318        assert_eq!(total, 3);
1319        // Progress ASC: Finished(0) < New(1) < Reading-with-progress(2).
1320        assert_eq!(books[0].title, "Finished Book");
1321        assert_eq!(books[1].title, "New Book");
1322        assert_eq!(books[2].title, "Halfway Book");
1323    }
1324
1325    #[test]
1326    fn page_books_pages_sort_orders_by_page_count() {
1327        let dir = tempfile::tempdir().expect("failed to create temp dir");
1328        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1329        db.init(0).expect("failed to run migrations");
1330
1331        let lib =
1332            Library::new(dir.path(), &db, "Pages Sort Library").expect("failed to create library");
1333
1334        for (fp_hex, path, title, pages) in [
1335            ("0000000000000801", "big.pdf", "Big Book", 500usize),
1336            ("0000000000000802", "tiny.pdf", "Tiny Book", 50),
1337            ("0000000000000803", "medium.pdf", "Medium Book", 200),
1338        ] {
1339            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1340            let mut info = make_info(path, title, fp);
1341            info.reader_info = Some(ReaderInfo {
1342                pages_count: pages,
1343                ..Default::default()
1344            });
1345            info.reader = info.reader_info.clone();
1346            lib.db
1347                .insert_book(lib.library_id, fp, &info)
1348                .expect("failed to insert book");
1349        }
1350
1351        lib.db
1352            .compute_sort_keys(lib.library_id)
1353            .expect("compute_sort_keys failed");
1354
1355        let (books, total) = lib
1356            .db
1357            .page_books(
1358                lib.library_id,
1359                Path::new(""),
1360                SortMethod::Pages,
1361                false,
1362                10,
1363                0,
1364            )
1365            .expect("page_books with Pages sort failed");
1366
1367        assert_eq!(total, 3);
1368        assert_eq!(books[0].title, "Tiny Book");
1369        assert_eq!(books[1].title, "Medium Book");
1370        assert_eq!(books[2].title, "Big Book");
1371    }
1372
1373    #[test]
1374    fn page_books_size_sort_orders_by_file_size() {
1375        let dir = tempfile::tempdir().expect("failed to create temp dir");
1376        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1377        db.init(0).expect("failed to run migrations");
1378
1379        let lib =
1380            Library::new(dir.path(), &db, "Size Sort Library").expect("failed to create library");
1381
1382        for (fp_hex, path, title, size) in [
1383            ("0000000000000901", "big.pdf", "Big Book", 9000u64),
1384            ("0000000000000902", "tiny.pdf", "Tiny Book", 100),
1385            ("0000000000000903", "medium.pdf", "Medium Book", 4500),
1386        ] {
1387            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1388            let mut info = make_info(path, title, fp);
1389            info.file.size = size;
1390            lib.db
1391                .insert_book(lib.library_id, fp, &info)
1392                .expect("failed to insert book");
1393        }
1394
1395        lib.db
1396            .compute_sort_keys(lib.library_id)
1397            .expect("compute_sort_keys failed");
1398
1399        let (books, total) = lib
1400            .db
1401            .page_books(
1402                lib.library_id,
1403                Path::new(""),
1404                SortMethod::Size,
1405                false,
1406                10,
1407                0,
1408            )
1409            .expect("page_books with Size sort failed");
1410
1411        assert_eq!(total, 3);
1412        assert_eq!(books[0].title, "Tiny Book");
1413        assert_eq!(books[1].title, "Medium Book");
1414        assert_eq!(books[2].title, "Big Book");
1415    }
1416
1417    #[test]
1418    fn page_books_kind_sort_orders_alphabetically_by_file_kind() {
1419        let dir = tempfile::tempdir().expect("failed to create temp dir");
1420        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1421        db.init(0).expect("failed to run migrations");
1422
1423        let lib =
1424            Library::new(dir.path(), &db, "Kind Sort Library").expect("failed to create library");
1425
1426        for (fp_hex, path, title, kind) in [
1427            ("0000000000000A01", "book.pdf", "PDF Book", "pdf"),
1428            ("0000000000000A02", "book.epub", "EPUB Book", "epub"),
1429            ("0000000000000A03", "book.cbz", "CBZ Book", "cbz"),
1430        ] {
1431            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1432            let mut info = make_info(path, title, fp);
1433            info.file.kind = kind.to_string();
1434            lib.db
1435                .insert_book(lib.library_id, fp, &info)
1436                .expect("failed to insert book");
1437        }
1438
1439        lib.db
1440            .compute_sort_keys(lib.library_id)
1441            .expect("compute_sort_keys failed");
1442
1443        let (books, total) = lib
1444            .db
1445            .page_books(
1446                lib.library_id,
1447                Path::new(""),
1448                SortMethod::Kind,
1449                false,
1450                10,
1451                0,
1452            )
1453            .expect("page_books with Kind sort failed");
1454
1455        assert_eq!(total, 3);
1456        // Alphabetical: cbz < epub < pdf.
1457        assert_eq!(books[0].title, "CBZ Book");
1458        assert_eq!(books[1].title, "EPUB Book");
1459        assert_eq!(books[2].title, "PDF Book");
1460    }
1461
1462    #[test]
1463    fn page_books_added_sort_orders_by_insertion_time() {
1464        use chrono::NaiveDateTime;
1465        let dir = tempfile::tempdir().expect("failed to create temp dir");
1466        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1467        db.init(0).expect("failed to run migrations");
1468
1469        let lib =
1470            Library::new(dir.path(), &db, "Added Sort Library").expect("failed to create library");
1471
1472        let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1473            .expect("invalid datetime");
1474        let t1 = NaiveDateTime::parse_from_str("2021-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
1475            .expect("invalid datetime");
1476        let t2 = NaiveDateTime::parse_from_str("2023-03-20 08:30:00", "%Y-%m-%d %H:%M:%S")
1477            .expect("invalid datetime");
1478
1479        for (fp_hex, path, title, added) in [
1480            ("0000000000000B01", "old.pdf", "Old Book", t0),
1481            ("0000000000000B02", "recent.pdf", "Recent Book", t2),
1482            ("0000000000000B03", "mid.pdf", "Middle Book", t1),
1483        ] {
1484            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1485            let mut info = make_info(path, title, fp);
1486            info.added = added;
1487            lib.db
1488                .insert_book(lib.library_id, fp, &info)
1489                .expect("failed to insert book");
1490        }
1491
1492        lib.db
1493            .compute_sort_keys(lib.library_id)
1494            .expect("compute_sort_keys failed");
1495
1496        let (books, total) = lib
1497            .db
1498            .page_books(
1499                lib.library_id,
1500                Path::new(""),
1501                SortMethod::Added,
1502                false,
1503                10,
1504                0,
1505            )
1506            .expect("page_books with Added sort failed");
1507
1508        assert_eq!(total, 3);
1509        assert_eq!(books[0].title, "Old Book");
1510        assert_eq!(books[1].title, "Middle Book");
1511        assert_eq!(books[2].title, "Recent Book");
1512    }
1513
1514    #[test]
1515    fn page_books_opened_sort_orders_by_last_opened_time() {
1516        use chrono::NaiveDateTime;
1517        let dir = tempfile::tempdir().expect("failed to create temp dir");
1518        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1519        db.init(0).expect("failed to run migrations");
1520
1521        let lib =
1522            Library::new(dir.path(), &db, "Opened Sort Library").expect("failed to create library");
1523
1524        let t0 = NaiveDateTime::parse_from_str("2020-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
1525            .expect("invalid datetime");
1526        let t1 = NaiveDateTime::parse_from_str("2022-09-10 09:00:00", "%Y-%m-%d %H:%M:%S")
1527            .expect("invalid datetime");
1528        let t2 = NaiveDateTime::parse_from_str("2024-04-01 17:45:00", "%Y-%m-%d %H:%M:%S")
1529            .expect("invalid datetime");
1530
1531        for (fp_hex, path, title, opened) in [
1532            ("0000000000000C01", "oldest.pdf", "Oldest Opened", t0),
1533            ("0000000000000C02", "newest.pdf", "Newest Opened", t2),
1534            ("0000000000000C03", "middle.pdf", "Middle Opened", t1),
1535        ] {
1536            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1537            let mut info = make_info(path, title, fp);
1538            info.reader_info = Some(ReaderInfo {
1539                opened,
1540                pages_count: 100,
1541                ..Default::default()
1542            });
1543            info.reader = info.reader_info.clone();
1544            lib.db
1545                .insert_book(lib.library_id, fp, &info)
1546                .expect("failed to insert book");
1547        }
1548
1549        lib.db
1550            .compute_sort_keys(lib.library_id)
1551            .expect("compute_sort_keys failed");
1552
1553        let (books, total) = lib
1554            .db
1555            .page_books(
1556                lib.library_id,
1557                Path::new(""),
1558                SortMethod::Opened,
1559                false,
1560                10,
1561                0,
1562            )
1563            .expect("page_books with Opened sort failed");
1564
1565        assert_eq!(total, 3);
1566        assert_eq!(books[0].title, "Oldest Opened");
1567        assert_eq!(books[1].title, "Middle Opened");
1568        assert_eq!(books[2].title, "Newest Opened");
1569    }
1570
1571    #[test]
1572    fn page_books_year_sort_orders_by_publication_year() {
1573        let dir = tempfile::tempdir().expect("failed to create temp dir");
1574        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1575        db.init(0).expect("failed to run migrations");
1576
1577        let lib =
1578            Library::new(dir.path(), &db, "Year Sort Library").expect("failed to create library");
1579
1580        for (fp_hex, path, title, year) in [
1581            ("0000000000000D01", "modern.pdf", "Modern Book", "2020"),
1582            ("0000000000000D02", "old.pdf", "Old Book", "1990"),
1583            ("0000000000000D03", "ancient.pdf", "Ancient Book", "1850"),
1584        ] {
1585            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1586            let mut info = make_info(path, title, fp);
1587            info.year = year.to_string();
1588            lib.db
1589                .insert_book(lib.library_id, fp, &info)
1590                .expect("failed to insert book");
1591        }
1592
1593        lib.db
1594            .compute_sort_keys(lib.library_id)
1595            .expect("compute_sort_keys failed");
1596
1597        let (books, total) = lib
1598            .db
1599            .page_books(
1600                lib.library_id,
1601                Path::new(""),
1602                SortMethod::Year,
1603                false,
1604                10,
1605                0,
1606            )
1607            .expect("page_books with Year sort failed");
1608
1609        assert_eq!(total, 3);
1610        assert_eq!(books[0].title, "Ancient Book");
1611        assert_eq!(books[1].title, "Old Book");
1612        assert_eq!(books[2].title, "Modern Book");
1613    }
1614
1615    #[test]
1616    fn page_books_count_query_respects_prefix_filter() {
1617        let dir = tempfile::tempdir().expect("failed to create temp dir");
1618        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1619        db.init(0).expect("failed to run migrations");
1620
1621        let lib = Library::new(dir.path(), &db, "Prefix Count Library")
1622            .expect("failed to create library");
1623
1624        for (fp_hex, path, title) in [
1625            ("0000000000000E01", "fiction/book1.pdf", "Fiction One"),
1626            ("0000000000000E02", "fiction/book2.pdf", "Fiction Two"),
1627            ("0000000000000E03", "nonfiction/book3.pdf", "Nonfiction One"),
1628        ] {
1629            let fp = Fp::from_str(fp_hex).expect("invalid fp");
1630            lib.db
1631                .insert_book(lib.library_id, fp, &make_info(path, title, fp))
1632                .expect("failed to insert book");
1633        }
1634
1635        lib.db
1636            .compute_sort_keys(lib.library_id)
1637            .expect("compute_sort_keys failed");
1638
1639        let (books, total) = lib
1640            .db
1641            .page_books(
1642                lib.library_id,
1643                Path::new("fiction"),
1644                SortMethod::Title,
1645                false,
1646                10,
1647                0,
1648            )
1649            .expect("page_books with prefix filter failed");
1650
1651        assert_eq!(total, 2, "count query should reflect the prefix filter");
1652        assert_eq!(books.len(), 2);
1653        assert!(books.iter().all(|b| b.title.starts_with("Fiction")));
1654    }
1655
1656    #[test]
1657    fn resolve_fingerprint_prefers_db_and_falls_back_to_filesystem() {
1658        let dir = tempfile::tempdir().expect("failed to create temp dir");
1659        let mut db = Database::new(":memory:").expect("failed to create in-memory database");
1660        db.init(0).expect("failed to run migrations");
1661
1662        let lib =
1663            Library::new(dir.path(), &db, "Fingerprint Library").expect("failed to create library");
1664
1665        let stored_fp = Fp::from_str("0000000000000201").expect("invalid stored fingerprint");
1666        let stored_info = make_info("stored.pdf", "Stored", stored_fp);
1667        lib.db
1668            .insert_book(lib.library_id, stored_fp, &stored_info)
1669            .expect("failed to insert stored book");
1670
1671        assert_eq!(
1672            lib.resolve_fingerprint(Path::new("stored.pdf")),
1673            Some(stored_fp)
1674        );
1675
1676        let fallback_path = dir.path().join("fallback.pdf");
1677        fs::write(&fallback_path, b"fallback content").expect("failed to write fallback file");
1678        let expected_fallback_fp = fallback_path
1679            .fingerprint()
1680            .expect("failed to fingerprint fallback file");
1681
1682        assert_eq!(
1683            lib.resolve_fingerprint(Path::new("fallback.pdf")),
1684            Some(expected_fallback_fp)
1685        );
1686        assert_eq!(lib.resolve_fingerprint(Path::new("missing.pdf")), None);
1687    }
1688}