Decouple DatabaseJson from files (#11)
Closes #10 Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/11
This commit is contained in:
parent
7400c5ae10
commit
83893f1e7e
@ -1,46 +1,39 @@
|
||||
//! Module for storing MusicHoard data in a JSON file database.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::fs::{read_to_string, write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{DatabaseRead, DatabaseWrite};
|
||||
|
||||
/// Trait for the JSON database backend.
|
||||
pub trait DatabaseJsonBackend {
|
||||
/// Read the JSON string from the backend.
|
||||
fn read(&self) -> Result<String, std::io::Error>;
|
||||
|
||||
/// Write the JSON string to the backend.
|
||||
fn write(&mut self, json: &str) -> Result<(), std::io::Error>;
|
||||
}
|
||||
|
||||
/// A JSON file database.
|
||||
pub struct DatabaseJson {
|
||||
database_file: File,
|
||||
backend: Box<dyn DatabaseJsonBackend>,
|
||||
}
|
||||
|
||||
impl DatabaseJson {
|
||||
/// Create a read-only database instance. If the JSON file does not exist, an error is returned.
|
||||
pub fn reader(path: &Path) -> Result<Self, std::io::Error> {
|
||||
Ok(Self {
|
||||
database_file: File::open(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a write-only database instance. If the file does not exist, it is created, if it does
|
||||
/// exist, it is truncated.
|
||||
pub fn writer(path: &Path) -> Result<Self, std::io::Error> {
|
||||
Ok(Self {
|
||||
database_file: File::create(path)?,
|
||||
})
|
||||
pub fn new(backend: Box<dyn DatabaseJsonBackend>) -> Self {
|
||||
DatabaseJson { backend }
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseRead for DatabaseJson {
|
||||
fn read<D>(&mut self, collection: &mut D) -> Result<(), std::io::Error>
|
||||
fn read<D>(&self, collection: &mut D) -> Result<(), std::io::Error>
|
||||
where
|
||||
D: DeserializeOwned,
|
||||
{
|
||||
// Read entire file to memory as for now this is faster than a buffered read from disk:
|
||||
// https://github.com/serde-rs/json/issues/160
|
||||
let mut serialized = String::new();
|
||||
self.database_file.read_to_string(&mut serialized)?;
|
||||
|
||||
let serialized = self.backend.read()?;
|
||||
*collection = serde_json::from_str(&serialized)?;
|
||||
Ok(())
|
||||
}
|
||||
@ -52,21 +45,54 @@ impl DatabaseWrite for DatabaseJson {
|
||||
S: Serialize,
|
||||
{
|
||||
let serialized = serde_json::to_string(&collection)?;
|
||||
self.database_file.write_all(serialized.as_bytes())?;
|
||||
self.backend.write(&serialized)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DatabaseJsonFile {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl DatabaseJsonFile {
|
||||
/// Create a database instance that will read/write to the provided path.
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
DatabaseJsonFile { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseJsonBackend for DatabaseJsonFile {
|
||||
fn read(&self) -> Result<String, std::io::Error> {
|
||||
// Read entire file to memory as for now this is faster than a buffered read from disk:
|
||||
// https://github.com/serde-rs/json/issues/160
|
||||
read_to_string(&self.path)
|
||||
}
|
||||
|
||||
fn write(&mut self, json: &str) -> Result<(), std::io::Error> {
|
||||
write(&self.path, json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::{Album, AlbumId, Track};
|
||||
|
||||
const TEST_FILENAME: &str = "tests/files/database_json_test.json";
|
||||
struct DatabaseJsonTest {
|
||||
json: String,
|
||||
}
|
||||
|
||||
impl DatabaseJsonBackend for DatabaseJsonTest {
|
||||
fn read(&self) -> Result<String, std::io::Error> {
|
||||
Ok(self.json.clone())
|
||||
}
|
||||
|
||||
fn write(&mut self, json: &str) -> Result<(), std::io::Error> {
|
||||
assert_eq!(self.json, json);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_data() -> Vec<Album> {
|
||||
vec![
|
||||
@ -109,55 +135,91 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
fn album_to_json(album: &Album) -> String {
|
||||
let album_id_artist = &album.id.artist;
|
||||
let album_id_year = album.id.year;
|
||||
let album_id_title = &album.id.title;
|
||||
|
||||
let mut album_tracks: Vec<String> = vec![];
|
||||
for track in album.tracks.iter() {
|
||||
let track_number = track.number;
|
||||
let track_title = &track.title;
|
||||
|
||||
let mut track_artist: Vec<String> = vec![];
|
||||
for artist in track.artist.iter() {
|
||||
track_artist.push(format!("\"{artist}\""))
|
||||
}
|
||||
let track_artist = track_artist.join(",");
|
||||
|
||||
album_tracks.push(format!(
|
||||
"{{\
|
||||
\"number\":{track_number},\
|
||||
\"title\":\"{track_title}\",\
|
||||
\"artist\":[{track_artist}]\
|
||||
}}"
|
||||
));
|
||||
}
|
||||
let album_tracks = album_tracks.join(",");
|
||||
|
||||
format!(
|
||||
"{{\
|
||||
\"id\":{{\
|
||||
\"artist\":\"{album_id_artist}\",\
|
||||
\"year\":{album_id_year},\
|
||||
\"title\":\"{album_id_title}\"\
|
||||
}},\"tracks\":[{album_tracks}]\
|
||||
}}"
|
||||
)
|
||||
}
|
||||
|
||||
fn albums_to_json(albums: &Vec<Album>) -> String {
|
||||
let mut albums_strings: Vec<String> = vec![];
|
||||
for album in albums.iter() {
|
||||
albums_strings.push(album_to_json(album));
|
||||
}
|
||||
let albums_json = albums_strings.join(",");
|
||||
format!("[{albums_json}]")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write() {
|
||||
let write_data = test_data();
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
DatabaseJson::writer(temp_file.path())
|
||||
.unwrap()
|
||||
let backend = DatabaseJsonTest {
|
||||
json: albums_to_json(&write_data),
|
||||
};
|
||||
|
||||
DatabaseJson::new(Box::new(backend))
|
||||
.write(&write_data)
|
||||
.unwrap();
|
||||
|
||||
let mut write_data_str = String::new();
|
||||
File::open(temp_file.path())
|
||||
.unwrap()
|
||||
.read_to_string(&mut write_data_str)
|
||||
.unwrap();
|
||||
|
||||
let mut test_data_str = String::new();
|
||||
File::open(TEST_FILENAME)
|
||||
.unwrap()
|
||||
.read_to_string(&mut test_data_str)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(write_data_str, test_data_str);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read() {
|
||||
let expected = test_data();
|
||||
let backend = DatabaseJsonTest {
|
||||
json: albums_to_json(&expected),
|
||||
};
|
||||
|
||||
let mut read_data: Vec<Album> = vec![];
|
||||
DatabaseJson::reader(Path::new(TEST_FILENAME))
|
||||
.unwrap()
|
||||
DatabaseJson::new(Box::new(backend))
|
||||
.read(&mut read_data)
|
||||
.unwrap();
|
||||
assert_eq!(read_data, test_data());
|
||||
|
||||
assert_eq!(read_data, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse() {
|
||||
let expected = test_data();
|
||||
let backend = DatabaseJsonTest {
|
||||
json: albums_to_json(&expected),
|
||||
};
|
||||
let mut database = DatabaseJson::new(Box::new(backend));
|
||||
|
||||
let write_data = test_data();
|
||||
let mut read_data: Vec<Album> = vec![];
|
||||
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
|
||||
DatabaseJson::writer(temp_file.path())
|
||||
.unwrap()
|
||||
.write(&write_data)
|
||||
.unwrap();
|
||||
DatabaseJson::reader(temp_file.path())
|
||||
.unwrap()
|
||||
.read(&mut read_data)
|
||||
.unwrap();
|
||||
database.write(&write_data).unwrap();
|
||||
database.read(&mut read_data).unwrap();
|
||||
|
||||
assert_eq!(write_data, read_data);
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ pub mod json;
|
||||
/// Trait for database reads.
|
||||
pub trait DatabaseRead {
|
||||
/// Read collection from the database.
|
||||
fn read<D>(&mut self, collection: &mut D) -> Result<(), std::io::Error>
|
||||
fn read<D>(&self, collection: &mut D) -> Result<(), std::io::Error>
|
||||
where
|
||||
D: DeserializeOwned;
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ impl QueryArgsBeets for Query {
|
||||
/// Trait for invoking beets commands.
|
||||
pub trait BeetsExecutor {
|
||||
/// Invoke beets with the provided arguments.
|
||||
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error>;
|
||||
fn exec(&mut self, arguments: &[String]) -> Result<Vec<String>, Error>;
|
||||
}
|
||||
|
||||
/// Struct for interacting with the music library via beets.
|
||||
@ -106,7 +106,7 @@ impl Beets {
|
||||
impl Library for Beets {
|
||||
fn list(&mut self, query: &Query) -> Result<Vec<Album>, Error> {
|
||||
let cmd = Self::list_cmd_and_args(query);
|
||||
let output = self.executor.exec(cmd)?;
|
||||
let output = self.executor.exec(&cmd)?;
|
||||
Self::list_to_albums(output)
|
||||
}
|
||||
}
|
||||
@ -209,7 +209,7 @@ impl Default for SystemExecutor {
|
||||
}
|
||||
|
||||
impl BeetsExecutor for SystemExecutor {
|
||||
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error> {
|
||||
fn exec(&mut self, arguments: &[String]) -> Result<Vec<String>, Error> {
|
||||
let output = Command::new(&self.bin).args(arguments).output()?;
|
||||
let output = std::str::from_utf8(&output.stdout)?;
|
||||
Ok(output.split('\n').map(|s| s.to_string()).collect())
|
||||
@ -226,10 +226,8 @@ mod tests {
|
||||
}
|
||||
|
||||
impl BeetsExecutor for TestExecutor {
|
||||
fn exec(&mut self, arguments: Vec<String>) -> Result<Vec<String>, Error> {
|
||||
if self.arguments.is_some() {
|
||||
assert_eq!(self.arguments.take().unwrap(), arguments);
|
||||
}
|
||||
fn exec(&mut self, arguments: &[String]) -> Result<Vec<String>, Error> {
|
||||
assert_eq!(&self.arguments.take().unwrap(), arguments);
|
||||
self.output.take().unwrap()
|
||||
}
|
||||
}
|
||||
@ -303,6 +301,14 @@ mod tests {
|
||||
strings
|
||||
}
|
||||
|
||||
fn albums_to_beets_string(albums: &Vec<Album>) -> Vec<String> {
|
||||
let mut strings = vec![];
|
||||
for album in albums.iter() {
|
||||
strings.append(&mut album_to_beets_string(album));
|
||||
}
|
||||
strings
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query() {
|
||||
let query = Query::new()
|
||||
@ -341,10 +347,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_list_ordered() {
|
||||
let expected = test_data();
|
||||
let mut output = vec![];
|
||||
for album in expected.iter() {
|
||||
output.append(&mut album_to_beets_string(album));
|
||||
}
|
||||
let output = albums_to_beets_string(&expected);
|
||||
|
||||
let executor = TestExecutor {
|
||||
arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]),
|
||||
@ -359,10 +362,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_list_unordered() {
|
||||
let mut expected = test_data();
|
||||
let mut output = vec![];
|
||||
for album in expected.iter() {
|
||||
output.append(&mut album_to_beets_string(album));
|
||||
}
|
||||
let mut output = albums_to_beets_string(&expected);
|
||||
let last = output.len() - 1;
|
||||
output.swap(0, last);
|
||||
|
||||
@ -391,10 +391,7 @@ mod tests {
|
||||
expected[1].id.year = expected[0].id.year;
|
||||
expected[1].id.title = expected[0].id.title.clone();
|
||||
|
||||
let mut output = vec![];
|
||||
for album in expected.iter() {
|
||||
output.append(&mut album_to_beets_string(album));
|
||||
}
|
||||
let output = albums_to_beets_string(&expected);
|
||||
|
||||
let executor = TestExecutor {
|
||||
arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]),
|
||||
|
@ -1 +0,0 @@
|
||||
[{"id":{"artist":"Artist A","year":1998,"title":"Release group A"},"tracks":[{"number":1,"title":"Track A.1","artist":["Artist A.A"]},{"number":2,"title":"Track A.2","artist":["Artist A.A"]},{"number":3,"title":"Track A.3","artist":["Artist A.A","Artist A.B"]}]},{"id":{"artist":"Artist B","year":2008,"title":"Release group B"},"tracks":[{"number":1,"title":"Track B.1","artist":["Artist B.A"]}]}]
|
Loading…
Reference in New Issue
Block a user