diff --git a/.gitignore b/.gitignore index eadf8f4..34d5d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /codecov +database.json diff --git a/src/database/json/mod.rs b/src/database/json/mod.rs index 5f1a42c..ea8c4fe 100644 --- a/src/database/json/mod.rs +++ b/src/database/json/mod.rs @@ -6,13 +6,19 @@ use serde::Serialize; #[cfg(test)] use mockall::automock; -use super::{Error, IDatabase}; +use super::{IDatabase, ReadError, WriteError}; pub mod backend; -impl From for Error { - fn from(err: serde_json::Error) -> Error { - Error::SerDeError(err.to_string()) +impl From for ReadError { + fn from(err: serde_json::Error) -> ReadError { + ReadError::SerDeError(err.to_string()) + } +} + +impl From for WriteError { + fn from(err: serde_json::Error) -> WriteError { + WriteError::SerDeError(err.to_string()) } } @@ -40,13 +46,13 @@ impl JsonDatabase { } impl IDatabase for JsonDatabase { - fn read(&self, collection: &mut D) -> Result<(), Error> { + fn read(&self, collection: &mut D) -> Result<(), ReadError> { let serialized = self.backend.read()?; *collection = serde_json::from_str(&serialized)?; Ok(()) } - fn write(&mut self, collection: &S) -> Result<(), Error> { + fn write(&mut self, collection: &S) -> Result<(), WriteError> { let serialized = serde_json::to_string(&collection)?; self.backend.write(&serialized)?; Ok(()) @@ -191,7 +197,7 @@ mod tests { let serde_err = serde_json::to_string(&object); assert!(serde_err.is_err()); - let serde_err: Error = serde_err.unwrap_err().into(); + let serde_err: WriteError = serde_err.unwrap_err().into(); assert!(!serde_err.to_string().is_empty()); assert!(!format!("{:?}", serde_err).is_empty()); } diff --git a/src/database/mod.rs b/src/database/mod.rs index 91bc687..a860673 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -14,35 +14,61 @@ pub mod json; #[cfg_attr(test, automock)] pub trait IDatabase { /// Read collection from the database. - fn read(&self, collection: &mut D) -> Result<(), Error>; + fn read(&self, collection: &mut D) -> Result<(), ReadError>; /// Write collection to the database. - fn write(&mut self, collection: &S) -> Result<(), Error>; + fn write(&mut self, collection: &S) -> Result<(), WriteError>; } /// Error type for database calls. #[derive(Debug)] -pub enum Error { - /// The database experienced an I/O error. +pub enum ReadError { + /// The database experienced an I/O read error. IoError(String), - /// The database experienced a (de)serialisation error. + /// The database experienced a deserialisation error. SerDeError(String), } -impl fmt::Display for Error { +impl fmt::Display for ReadError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { - Self::IoError(ref s) => write!(f, "the database experienced an I/O error: {s}"), + Self::IoError(ref s) => write!(f, "the database experienced an I/O read error: {s}"), Self::SerDeError(ref s) => { - write!(f, "the database experienced a (de)serialisation error: {s}") + write!(f, "the database experienced a deserialisation error: {s}") } } } } -impl From for Error { - fn from(err: std::io::Error) -> Error { - Error::IoError(err.to_string()) +impl From for ReadError { + fn from(err: std::io::Error) -> ReadError { + ReadError::IoError(err.to_string()) + } +} + +/// Error type for database calls. +#[derive(Debug)] +pub enum WriteError { + /// The database experienced an I/O write error. + IoError(String), + /// The database experienced a serialisation error. + SerDeError(String), +} + +impl fmt::Display for WriteError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::IoError(ref s) => write!(f, "the database experienced an I/O write error: {s}"), + Self::SerDeError(ref s) => { + write!(f, "the database experienced a serialisation error: {s}") + } + } + } +} + +impl From for WriteError { + fn from(err: std::io::Error) -> WriteError { + WriteError::IoError(err.to_string()) } } @@ -50,11 +76,15 @@ impl From for Error { mod tests { use std::io; - use super::Error; + use super::{ReadError, WriteError}; #[test] fn errors() { - let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "error").into(); + let io_err: ReadError = io::Error::new(io::ErrorKind::Interrupted, "error").into(); + assert!(!io_err.to_string().is_empty()); + assert!(!format!("{:?}", io_err).is_empty()); + + let io_err: WriteError = io::Error::new(io::ErrorKind::Interrupted, "error").into(); assert!(!io_err.to_string().is_empty()); assert!(!format!("{:?}", io_err).is_empty()); } diff --git a/src/lib.rs b/src/lib.rs index bd7a147..db3d4cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,8 @@ use std::{ cmp::Ordering, collections::{HashMap, HashSet}, fmt, - iter::Peekable, mem, + iter::Peekable, + mem, }; use database::IDatabase; @@ -212,8 +213,14 @@ impl From for Error { } } -impl From for Error { - fn from(err: database::Error) -> Error { +impl From for Error { + fn from(err: database::ReadError) -> Error { + Error::DatabaseError(err.to_string()) + } +} + +impl From for Error { + fn from(err: database::WriteError) -> Error { Error::DatabaseError(err.to_string()) } } @@ -521,7 +528,7 @@ mod tests { let library = MockILibrary::new(); let mut database = MockIDatabase::new(); - let database_result = Err(database::Error::IoError(String::from("I/O error"))); + let database_result = Err(database::WriteError::IoError(String::from("I/O error"))); database .expect_write() @@ -531,8 +538,9 @@ mod tests { let mut music_hoard = MusicHoard::new(library, database); let actual_err = music_hoard.save_to_database().unwrap_err(); - let expected_err = - Error::DatabaseError(database::Error::IoError(String::from("I/O error")).to_string()); + let expected_err = Error::DatabaseError( + database::WriteError::IoError(String::from("I/O error")).to_string(), + ); assert_eq!(actual_err, expected_err); assert_eq!(actual_err.to_string(), expected_err.to_string()); diff --git a/src/main.rs b/src/main.rs index 143d78c..f6de28c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ +use std::fs::OpenOptions; use std::path::PathBuf; use std::{ffi::OsString, io}; +use musichoard::Collection; use ratatui::{backend::CrosstermBackend, Terminal}; use structopt::StructOpt; @@ -57,9 +59,28 @@ fn with(lib: LIB, db: DB) { } fn main() { - // Create the application. let opt = Opt::from_args(); + // Create an empty database file if it does not exist. + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&opt.database_file_path) + { + Ok(f) => { + drop(f); + JsonDatabase::new(JsonDatabaseFileBackend::new(&opt.database_file_path)) + .write::(&vec![]) + .expect("failed to create empty database"); + } + Err(e) => match e.kind() { + io::ErrorKind::AlreadyExists => {} + _ => panic!("failed to access database file"), + }, + } + + // Create the application. + let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path); if let Some(uri) = opt.beets_ssh_uri { let uri = uri.into_string().expect("invalid SSH URI"); let beets_config_file_path = opt @@ -70,11 +91,9 @@ fn main() { let lib_exec = BeetsLibrarySshExecutor::new(uri) .expect("failed to initialise beets") .config(beets_config_file_path); - let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path); with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec)); } else { let lib_exec = BeetsLibraryProcessExecutor::default().config(opt.beets_config_file_path); - let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path); with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec)); } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 0cb188f..bae2541 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -2,11 +2,14 @@ use crossterm::event::{KeyEvent, MouseEvent}; use std::fmt; use std::sync::mpsc; +use super::ui::UiError; + #[derive(Debug)] pub enum EventError { Send(Event), Recv, Io(std::io::Error), + Ui(String), } impl fmt::Display for EventError { @@ -17,6 +20,9 @@ impl fmt::Display for EventError { Self::Io(ref e) => { write!(f, "an I/O error was triggered during event handling: {e}") } + Self::Ui(ref s) => { + write!(f, "the UI returned an error during event handling: {s}") + } } } } @@ -33,6 +39,12 @@ impl From for EventError { } } +impl From for EventError { + fn from(err: UiError) -> EventError { + EventError::Ui(err.to_string()) + } +} + #[derive(Clone, Copy, Debug)] pub enum Event { Key(KeyEvent), diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 41de726..5da457f 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -14,7 +14,8 @@ pub trait IEventHandler { } trait IEventHandlerPrivate { - fn handle_key_event(ui: &mut UI, key_event: KeyEvent); + fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError>; + fn quit(ui: &mut UI) -> Result<(), EventError>; } pub struct EventHandler { @@ -31,7 +32,7 @@ impl EventHandler { impl IEventHandler for EventHandler { fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError> { match self.events.recv()? { - Event::Key(key_event) => Self::handle_key_event(ui, key_event), + Event::Key(key_event) => Self::handle_key_event(ui, key_event)?, Event::Mouse(_) => {} Event::Resize(_, _) => {} }; @@ -40,16 +41,16 @@ impl IEventHandler for EventHandler { } impl IEventHandlerPrivate for EventHandler { - fn handle_key_event(ui: &mut UI, key_event: KeyEvent) { + fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError> { match key_event.code { // Exit application on `ESC` or `q`. KeyCode::Esc | KeyCode::Char('q') => { - ui.quit(); + Self::quit(ui)?; } // Exit application on `Ctrl-C`. KeyCode::Char('c') | KeyCode::Char('C') => { if key_event.modifiers == KeyModifiers::CONTROL { - ui.quit(); + Self::quit(ui)?; } } // Category change. @@ -69,6 +70,14 @@ impl IEventHandlerPrivate for EventHandler { // Other keys. _ => {} } + + Ok(()) + } + + fn quit(ui: &mut UI) -> Result<(), EventError> { + ui.quit(); + ui.save()?; + Ok(()) } } // GRCOV_EXCL_STOP diff --git a/src/tui/lib.rs b/src/tui/lib.rs index 89a6242..6e0facd 100644 --- a/src/tui/lib.rs +++ b/src/tui/lib.rs @@ -3,24 +3,26 @@ use musichoard::{database::IDatabase, library::ILibrary, Collection, MusicHoard} #[cfg(test)] use mockall::automock; -use super::Error; - #[cfg_attr(test, automock)] pub trait IMusicHoard { - fn rescan_library(&mut self) -> Result<(), Error>; + fn rescan_library(&mut self) -> Result<(), musichoard::Error>; + fn load_from_database(&mut self) -> Result<(), musichoard::Error>; + fn save_to_database(&mut self) -> Result<(), musichoard::Error>; fn get_collection(&self) -> &Collection; } -impl From for Error { - fn from(err: musichoard::Error) -> Error { - Error::Lib(err.to_string()) - } -} - // GRCOV_EXCL_START impl IMusicHoard for MusicHoard { - fn rescan_library(&mut self) -> Result<(), Error> { - Ok(MusicHoard::rescan_library(self)?) + fn rescan_library(&mut self) -> Result<(), musichoard::Error> { + MusicHoard::rescan_library(self) + } + + fn load_from_database(&mut self) -> Result<(), musichoard::Error> { + MusicHoard::load_from_database(self) + } + + fn save_to_database(&mut self) -> Result<(), musichoard::Error> { + MusicHoard::save_to_database(self) } fn get_collection(&self) -> &Collection { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8e99637..4236004 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -25,6 +25,12 @@ pub enum Error { ListenerPanic, } +impl From for Error { + fn from(err: musichoard::Error) -> Error { + Error::Lib(err.to_string()) + } +} + impl From for Error { fn from(err: io::Error) -> Error { Error::Io(err.to_string()) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5461278..27c3981 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,3 +1,5 @@ +use std::fmt; + use musichoard::{Album, Artist, Collection, Format, Track}; use ratatui::{ backend::Backend, @@ -9,10 +11,31 @@ use ratatui::{ use super::{lib::IMusicHoard, Error}; +#[derive(Debug)] +pub enum UiError { + Lib(String), +} + +impl fmt::Display for UiError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::Lib(ref s) => write!(f, "the musichoard library returned an error: {s}"), + } + } +} + +impl From for UiError { + fn from(err: musichoard::Error) -> UiError { + UiError::Lib(err.to_string()) + } +} + pub trait IUi { fn is_running(&self) -> bool; fn quit(&mut self); + fn save(&mut self) -> Result<(), UiError>; + fn increment_category(&mut self); fn decrement_category(&mut self); @@ -440,6 +463,7 @@ impl<'a, 'b> TrackState<'a, 'b> { impl Ui { pub fn new(mut music_hoard: MH) -> Result { + music_hoard.load_from_database()?; music_hoard.rescan_library()?; let selection = Selection::new(Some(music_hoard.get_collection())); Ok(Ui { @@ -531,6 +555,11 @@ impl IUi for Ui { self.running = false; } + fn save(&mut self) -> Result<(), UiError> { + self.music_hoard.save_to_database()?; + Ok(()) + } + fn increment_category(&mut self) { self.selection.increment_category(); }