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