musichoard/src/lib.rs

1017 lines
29 KiB
Rust

//! MusicHoard - a music collection manager.
pub mod database;
pub mod library;
use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
fmt,
iter::Peekable,
mem,
};
use database::IDatabase;
use library::{ILibrary, Item, Query};
use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
/// An object with the [`IUrl`] trait contains a valid URL.
pub trait IUrl {
fn url(&self) -> &str;
}
/// An object with the [`IMbid`] trait contains a [MusicBrainz
/// Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID).
pub trait IMbid {
fn mbid(&self) -> &str;
}
#[derive(Debug)]
enum UrlType {
MusicBrainz,
MusicButler,
Bandcamp,
Qobuz,
}
struct InvalidUrlError {
url_type: UrlType,
url: String,
}
impl fmt::Display for InvalidUrlError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "invalid url of type {:?}: {}", self.url_type, self.url)
}
}
/// MusicBrainz reference.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct MusicBrainz(Url);
impl MusicBrainz {
pub fn new<S: AsRef<str>>(url: S) -> Result<Self, Error> {
let url = Url::parse(url.as_ref())?;
if !url
.domain()
.map(|u| u.ends_with("musicbrainz.org"))
.unwrap_or(false)
{
return Err(Self::invalid_url_error(url).into());
}
match url.path_segments().and_then(|mut ps| ps.nth(1)) {
Some(segment) => Uuid::try_parse(segment)?,
None => return Err(Self::invalid_url_error(url).into()),
};
Ok(MusicBrainz(url))
}
fn invalid_url_error<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::MusicBrainz,
url: url.into(),
}
}
}
impl IUrl for MusicBrainz {
fn url(&self) -> &str {
self.0.as_str()
}
}
impl IMbid for MusicBrainz {
fn mbid(&self) -> &str {
// The URL is assumed to have been validated.
self.0.path_segments().and_then(|mut ps| ps.nth(1)).unwrap()
}
}
/// MusicButler reference.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct MusicButler(Url);
impl MusicButler {
pub fn new<S: AsRef<str>>(url: S) -> Result<Self, Error> {
let url = Url::parse(url.as_ref())?;
if !url
.domain()
.map(|u| u.ends_with("musicbutler.io"))
.unwrap_or(false)
{
return Err(Self::invalid_url_error(url).into());
}
Ok(MusicButler(url))
}
fn invalid_url_error<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::MusicButler,
url: url.into(),
}
}
}
impl IUrl for MusicButler {
fn url(&self) -> &str {
self.0.as_str()
}
}
/// Bandcamp reference.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Bandcamp(Url);
impl Bandcamp {
pub fn new<S: AsRef<str>>(url: S) -> Result<Self, Error> {
let url = Url::parse(url.as_ref())?;
if !url
.domain()
.map(|u| u.ends_with("bandcamp.com"))
.unwrap_or(false)
{
return Err(Self::invalid_url_error(url).into());
}
Ok(Bandcamp(url))
}
fn invalid_url_error<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::Bandcamp,
url: url.into(),
}
}
}
impl IUrl for Bandcamp {
fn url(&self) -> &str {
self.0.as_str()
}
}
/// Qobuz reference.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Qobuz(Url);
impl Qobuz {
pub fn new<S: AsRef<str>>(url: S) -> Result<Self, Error> {
let url = Url::parse(url.as_ref())?;
if !url
.domain()
.map(|u| u.ends_with("qobuz.com"))
.unwrap_or(false)
{
return Err(Self::invalid_url_error(url).into());
}
Ok(Qobuz(url))
}
fn invalid_url_error<S: Into<String>>(url: S) -> InvalidUrlError {
InvalidUrlError {
url_type: UrlType::Qobuz,
url: url.into(),
}
}
}
impl IUrl for Qobuz {
fn url(&self) -> &str {
self.0.as_str()
}
}
/// The track file format.
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub enum Format {
Flac,
Mp3,
}
/// The track quality. Combines format and bitrate information.
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Quality {
pub format: Format,
pub bitrate: u32,
}
/// The track identifier.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TrackId {
pub number: u32,
pub title: String,
}
/// A single track on an album.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Track {
pub id: TrackId,
pub artist: Vec<String>,
pub quality: Quality,
}
impl PartialOrd for Track {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.id.partial_cmp(&other.id)
}
}
impl Ord for Track {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl Merge for Track {
fn merge(self, other: Self) -> Self {
assert_eq!(self.id, other.id);
self
}
}
/// The album identifier.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Hash)]
pub struct AlbumId {
pub year: u32,
pub title: String,
}
/// An album is a collection of tracks that were released together.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Album {
pub id: AlbumId,
pub tracks: Vec<Track>,
}
impl PartialOrd for Album {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.id.partial_cmp(&other.id)
}
}
impl Ord for Album {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl Merge for Album {
fn merge(mut self, other: Self) -> Self {
assert_eq!(self.id, other.id);
self.tracks = MergeSorted::new(self.tracks.into_iter(), other.tracks.into_iter()).collect();
self
}
}
/// The artist identifier.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ArtistId {
pub name: String,
}
/// The artist properties.
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct ArtistProperties {
pub musicbrainz: Option<MusicBrainz>,
pub musicbutler: Vec<MusicButler>,
pub bandcamp: Vec<Bandcamp>,
pub qobuz: Option<Qobuz>,
}
impl Merge for ArtistProperties {
fn merge(mut self, other: Self) -> Self {
self.musicbrainz = Self::merge_opts(self.musicbrainz, other.musicbrainz);
self.musicbutler = Self::merge_vecs(self.musicbutler, other.musicbutler);
self.bandcamp = Self::merge_vecs(self.bandcamp, other.bandcamp);
self.qobuz = Self::merge_opts(self.qobuz, other.qobuz);
self
}
}
/// An artist.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Artist {
pub id: ArtistId,
pub properties: ArtistProperties,
pub albums: Vec<Album>,
}
impl PartialOrd for Artist {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.id.partial_cmp(&other.id)
}
}
impl Ord for Artist {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl Merge for Artist {
fn merge(mut self, other: Self) -> Self {
assert_eq!(self.id, other.id);
self.properties = self.properties.merge(other.properties);
self.albums = MergeSorted::new(self.albums.into_iter(), other.albums.into_iter()).collect();
self
}
}
/// The collection type. Currently, a collection is a list of artists.
pub type Collection = Vec<Artist>;
trait Merge {
fn merge(self, other: Self) -> Self;
fn merge_opts<T>(this: Option<T>, other: Option<T>) -> Option<T> {
match &this {
Some(_) => this,
None => other,
}
}
fn merge_vecs<T: Ord + Eq>(mut this: Vec<T>, mut other: Vec<T>) -> Vec<T> {
this.append(&mut other);
this.sort_unstable();
this.dedup();
this
}
}
struct MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
{
left: Peekable<L>,
right: Peekable<R>,
}
impl<L, R> MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
{
fn new(left: L, right: R) -> MergeSorted<L, R> {
MergeSorted {
left: left.peekable(),
right: right.peekable(),
}
}
}
impl<L, R> Iterator for MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
L::Item: Ord + Merge,
{
type Item = L::Item;
fn next(&mut self) -> Option<L::Item> {
let which = match (self.left.peek(), self.right.peek()) {
(Some(l), Some(r)) => l.cmp(r),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => return None,
};
match which {
Ordering::Less => self.left.next(),
Ordering::Equal => Some(self.left.next().unwrap().merge(self.right.next().unwrap())),
Ordering::Greater => self.right.next(),
}
}
}
/// Error type for `musichoard`.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// The [`MusicHoard`] failed to read/write from/to the library.
LibraryError(String),
/// The [`MusicHoard`] failed to read/write from/to the database.
DatabaseError(String),
/// The [`MusicHoard`] failed to parse a user-provided URL.
UrlParseError(String),
/// The user-provided URL is not valid.
InvalidUrlError(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"),
Self::DatabaseError(ref s) => {
write!(f, "failed to read/write from/to the database: {s}")
}
Self::UrlParseError(ref s) => write!(f, "failed to parse a user-provided URL: {s}"),
Self::InvalidUrlError(ref s) => write!(f, "user-provided URL is invalid: {s}"),
}
}
}
impl From<library::Error> for Error {
fn from(err: library::Error) -> Error {
Error::LibraryError(err.to_string())
}
}
impl From<database::LoadError> for Error {
fn from(err: database::LoadError) -> Error {
Error::DatabaseError(err.to_string())
}
}
impl From<database::SaveError> for Error {
fn from(err: database::SaveError) -> Error {
Error::DatabaseError(err.to_string())
}
}
impl From<url::ParseError> for Error {
fn from(err: url::ParseError) -> Error {
Error::UrlParseError(err.to_string())
}
}
impl From<uuid::Error> for Error {
fn from(err: uuid::Error) -> Error {
Error::UrlParseError(err.to_string())
}
}
impl From<InvalidUrlError> for Error {
fn from(err: InvalidUrlError) -> Error {
Error::InvalidUrlError(err.to_string())
}
}
/// The Music Hoard. It is responsible for pulling information from both the library and the
/// database, ensuring its consistent and writing back any changes.
pub struct MusicHoard<LIB, DB> {
library: LIB,
database: DB,
collection: Collection,
}
impl<LIB: ILibrary, DB: IDatabase> MusicHoard<LIB, DB> {
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`].
pub fn new(library: LIB, database: DB) -> Self {
MusicHoard {
library,
database,
collection: vec![],
}
}
pub fn rescan_library(&mut self) -> Result<(), Error> {
let items = self.library.list(&Query::new())?;
let mut library = Self::items_to_artists(items);
Self::sort(&mut library);
let collection = mem::take(&mut self.collection);
self.collection = Self::merge(library, collection);
Ok(())
}
pub fn load_from_database(&mut self) -> Result<(), Error> {
let mut database: Collection = vec![];
self.database.load(&mut database)?;
Self::sort(&mut database);
let collection = mem::take(&mut self.collection);
self.collection = Self::merge(collection, database);
Ok(())
}
pub fn save_to_database(&mut self) -> Result<(), Error> {
self.database.save(&self.collection)?;
Ok(())
}
pub fn get_collection(&self) -> &Collection {
&self.collection
}
fn sort(collection: &mut [Artist]) {
collection.sort_unstable();
for artist in collection.iter_mut() {
artist.albums.sort_unstable();
for album in artist.albums.iter_mut() {
album.tracks.sort_unstable();
}
}
}
fn merge(primary: Vec<Artist>, secondary: Vec<Artist>) -> Vec<Artist> {
MergeSorted::new(primary.into_iter(), secondary.into_iter()).collect()
}
fn items_to_artists(items: Vec<Item>) -> Vec<Artist> {
let mut artists: Vec<Artist> = vec![];
let mut album_ids = HashMap::<ArtistId, HashSet<AlbumId>>::new();
for item in items.into_iter() {
let artist_id = ArtistId {
name: item.album_artist,
};
let album_id = AlbumId {
year: item.album_year,
title: item.album_title,
};
let track = Track {
id: TrackId {
number: item.track_number,
title: item.track_title,
},
artist: item.track_artist,
quality: Quality {
format: item.track_format,
bitrate: item.track_bitrate,
},
};
let artist = if album_ids.contains_key(&artist_id) {
// Assume results are in some order which means they will likely be grouped by
// artist. Therefore, we look from the back since the last inserted artist is most
// likely the one we are looking for.
artists
.iter_mut()
.rev()
.find(|a| a.id == artist_id)
.unwrap()
} else {
album_ids.insert(artist_id.clone(), HashSet::<AlbumId>::new());
artists.push(Artist {
id: artist_id.clone(),
properties: ArtistProperties::default(),
albums: vec![],
});
artists.last_mut().unwrap()
};
if album_ids[&artist_id].contains(&album_id) {
// Assume results are in some order which means they will likely be grouped by
// album. Therefore, we look from the back since the last inserted album is most
// likely the one we are looking for.
let album = artist
.albums
.iter_mut()
.rev()
.find(|a| a.id == album_id)
.unwrap();
album.tracks.push(track);
} else {
album_ids
.get_mut(&artist_id)
.unwrap()
.insert(album_id.clone());
artist.albums.push(Album {
id: album_id,
tracks: vec![track],
});
}
}
artists
}
}
#[cfg(test)]
#[macro_use]
mod testlib;
#[cfg(test)]
mod tests {
use mockall::predicate;
use once_cell::sync::Lazy;
use crate::{database::MockIDatabase, library::MockILibrary};
use super::*;
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| collection!());
pub fn artist_to_items(artist: &Artist) -> Vec<Item> {
let mut items = vec![];
for album in artist.albums.iter() {
for track in album.tracks.iter() {
items.push(Item {
album_artist: artist.id.name.clone(),
album_year: album.id.year,
album_title: album.id.title.clone(),
track_number: track.id.number,
track_title: track.id.title.clone(),
track_artist: track.artist.clone(),
track_format: track.quality.format,
track_bitrate: track.quality.bitrate,
});
}
}
items
}
pub fn artists_to_items(artists: &[Artist]) -> Vec<Item> {
let mut items = vec![];
for artist in artists.iter() {
items.append(&mut artist_to_items(artist));
}
items
}
fn clean_collection(mut collection: Collection) -> Collection {
for artist in collection.iter_mut() {
artist.properties = ArtistProperties::default();
}
collection
}
#[test]
fn musicbrainz() {
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
let url = format!("https://musicbrainz.org/artist/{uuid}");
let mb = MusicBrainz::new(&url).unwrap();
assert_eq!(url, mb.url());
assert_eq!(uuid, mb.mbid());
let url = format!("not a url at all");
let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into();
let actual_error = MusicBrainz::new(&url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
let url = format!("https://musicbrainz.org/artist/i-am-not-a-uuid");
let expected_error: Error = Uuid::try_parse("i-am-not-a-uuid").unwrap_err().into();
let actual_error = MusicBrainz::new(&url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
let url = format!("https://musicbrainz.org/artist");
let expected_error: Error = InvalidUrlError {
url_type: UrlType::MusicBrainz,
url: url.clone(),
}
.into();
let actual_error = MusicBrainz::new(&url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
#[test]
fn urls() {
let musicbrainz = "https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8";
let musicbutler = "https://www.musicbutler.io/artist-page/483340948";
let bandcamp = "https://thelasthangmen.bandcamp.com/";
let qobuz = "https://www.qobuz.com/nl-nl/interpreter/the-last-hangmen/1244413";
assert!(MusicBrainz::new(&musicbrainz).is_ok());
assert!(MusicBrainz::new(&musicbutler).is_err());
assert!(MusicBrainz::new(&bandcamp).is_err());
assert!(MusicBrainz::new(&qobuz).is_err());
assert!(MusicButler::new(&musicbrainz).is_err());
assert!(MusicButler::new(&musicbutler).is_ok());
assert!(MusicButler::new(&bandcamp).is_err());
assert!(MusicButler::new(&qobuz).is_err());
assert!(Bandcamp::new(&musicbrainz).is_err());
assert!(Bandcamp::new(&musicbutler).is_err());
assert!(Bandcamp::new(&bandcamp).is_ok());
assert!(Bandcamp::new(&qobuz).is_err());
assert!(Qobuz::new(&musicbrainz).is_err());
assert!(Qobuz::new(&musicbutler).is_err());
assert!(Qobuz::new(&bandcamp).is_err());
assert!(Qobuz::new(&qobuz).is_ok());
}
#[test]
fn merge_track() {
let left = Track {
id: TrackId {
number: 04,
title: String::from("a title"),
},
artist: vec![String::from("left artist")],
quality: Quality {
format: Format::Flac,
bitrate: 1411,
},
};
let right = Track {
id: left.id.clone(),
artist: vec![String::from("right artist")],
quality: Quality {
format: Format::Mp3,
bitrate: 320,
},
};
let merged = left.clone().merge(right);
assert_eq!(left, merged);
}
#[test]
fn merge_album_no_overlap() {
let left = COLLECTION[0].albums[0].to_owned();
let mut right = COLLECTION[0].albums[1].to_owned();
right.id = left.id.clone();
let mut expected = left.clone();
expected.tracks.append(&mut right.tracks.clone());
expected.tracks.sort_unstable();
let merged = left.clone().merge(right);
assert_eq!(expected, merged);
}
#[test]
fn merge_album_overlap() {
let mut left = COLLECTION[0].albums[0].to_owned();
let mut right = COLLECTION[0].albums[1].to_owned();
right.id = left.id.clone();
left.tracks.push(right.tracks[0].clone());
left.tracks.sort_unstable();
let mut expected = left.clone();
expected.tracks.append(&mut right.tracks.clone());
expected.tracks.sort_unstable();
expected.tracks.dedup();
let merged = left.clone().merge(right);
assert_eq!(expected, merged);
}
#[test]
fn merge_artist_no_overlap() {
let left = COLLECTION[0].to_owned();
let mut right = COLLECTION[1].to_owned();
right.id = left.id.clone();
let mut expected = left.clone();
expected.properties = expected.properties.merge(right.clone().properties);
expected.albums.append(&mut right.albums.clone());
expected.albums.sort_unstable();
let merged = left.clone().merge(right);
assert_eq!(expected, merged);
}
#[test]
fn merge_artist_overlap() {
let mut left = COLLECTION[0].to_owned();
let mut right = COLLECTION[1].to_owned();
right.id = left.id.clone();
left.albums.push(right.albums[0].clone());
left.albums.sort_unstable();
let mut expected = left.clone();
expected.properties = expected.properties.merge(right.clone().properties);
expected.albums.append(&mut right.albums.clone());
expected.albums.sort_unstable();
expected.albums.dedup();
let merged = left.clone().merge(right);
assert_eq!(expected, merged);
}
#[test]
fn merge_collection_no_overlap() {
let half: usize = COLLECTION.len() / 2;
let left = COLLECTION[..half].to_owned();
let right = COLLECTION[half..].to_owned();
let mut expected = COLLECTION.to_owned();
expected.sort_unstable();
let merged = MusicHoard::<MockILibrary, MockIDatabase>::merge(left.clone(), right);
assert_eq!(expected, merged);
}
#[test]
fn merge_collection_overlap() {
let half: usize = COLLECTION.len() / 2;
let left = COLLECTION[..(half + 1)].to_owned();
let right = COLLECTION[half..].to_owned();
let mut expected = COLLECTION.to_owned();
expected.sort_unstable();
let merged = MusicHoard::<MockILibrary, MockIDatabase>::merge(left.clone(), right);
assert_eq!(expected, merged);
}
#[test]
fn rescan_library_ordered() {
let mut library = MockILibrary::new();
let database = MockIDatabase::new();
let library_input = Query::new();
let library_result = Ok(artists_to_items(&COLLECTION));
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::new(library, database);
music_hoard.rescan_library().unwrap();
assert_eq!(
music_hoard.get_collection(),
&clean_collection(COLLECTION.to_owned())
);
}
#[test]
fn rescan_library_unordered() {
let mut library = MockILibrary::new();
let database = MockIDatabase::new();
let library_input = Query::new();
let mut library_result = Ok(artists_to_items(&COLLECTION));
// Swap the last item with the first.
let last = library_result.as_ref().unwrap().len() - 1;
library_result.as_mut().unwrap().swap(0, last);
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::new(library, database);
music_hoard.rescan_library().unwrap();
assert_eq!(
music_hoard.get_collection(),
&clean_collection(COLLECTION.to_owned())
);
}
#[test]
fn rescan_library_album_title_year_clash() {
let mut library = MockILibrary::new();
let database = MockIDatabase::new();
let mut expected = clean_collection(COLLECTION.to_owned());
expected[0].albums[0].id.year = expected[1].albums[0].id.year;
expected[0].albums[0].id.title = expected[1].albums[0].id.title.clone();
let library_input = Query::new();
let library_result = Ok(artists_to_items(&expected));
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::new(library, database);
music_hoard.rescan_library().unwrap();
assert_eq!(music_hoard.get_collection(), &expected);
}
#[test]
fn load_database() {
let library = MockILibrary::new();
let mut database = MockIDatabase::new();
database
.expect_load()
.times(1)
.return_once(|coll: &mut Collection| {
*coll = COLLECTION.to_owned();
Ok(())
});
let mut music_hoard = MusicHoard::new(library, database);
music_hoard.load_from_database().unwrap();
assert_eq!(music_hoard.get_collection(), &*COLLECTION);
}
#[test]
fn rescan_get_save() {
let mut library = MockILibrary::new();
let mut database = MockIDatabase::new();
let library_input = Query::new();
let library_result = Ok(artists_to_items(&COLLECTION));
let database_input = clean_collection(COLLECTION.to_owned());
let database_result = Ok(());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
database
.expect_save()
.with(predicate::eq(database_input))
.times(1)
.return_once(|_: &Collection| database_result);
let mut music_hoard = MusicHoard::new(library, database);
music_hoard.rescan_library().unwrap();
assert_eq!(
music_hoard.get_collection(),
&clean_collection(COLLECTION.to_owned())
);
music_hoard.save_to_database().unwrap();
}
#[test]
fn library_error() {
let mut library = MockILibrary::new();
let database = MockIDatabase::new();
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
library
.expect_list()
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::new(library, database);
let actual_err = music_hoard.rescan_library().unwrap_err();
let expected_err =
Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string());
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
#[test]
fn database_load_error() {
let library = MockILibrary::new();
let mut database = MockIDatabase::new();
let database_result = Err(database::LoadError::IoError(String::from("I/O error")));
database
.expect_load()
.times(1)
.return_once(|_: &mut Collection| database_result);
let mut music_hoard = MusicHoard::new(library, database);
let actual_err = music_hoard.load_from_database().unwrap_err();
let expected_err = Error::DatabaseError(
database::LoadError::IoError(String::from("I/O error")).to_string(),
);
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
#[test]
fn database_save_error() {
let library = MockILibrary::new();
let mut database = MockIDatabase::new();
let database_result = Err(database::SaveError::IoError(String::from("I/O error")));
database
.expect_save()
.times(1)
.return_once(|_: &Collection| database_result);
let mut music_hoard = MusicHoard::new(library, database);
let actual_err = music_hoard.save_to_database().unwrap_err();
let expected_err = Error::DatabaseError(
database::SaveError::IoError(String::from("I/O error")).to_string(),
);
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
}