From 125bc24ed0b2f4c9b98f444ccb4cb18e4d01bf25 Mon Sep 17 00:00:00 2001 From: Rekai Musuka Date: Tue, 2 Mar 2021 20:21:57 -0600 Subject: [PATCH] feat: Implement list info, and drop commands Save Sync now has persistent storage. Currently, you can add new Saves, Remove them, and get info about them. The part of CRUD that is remaining is Update. Documentation needs to be written, a lot of the public API changed. --- Cargo.toml | 1 + client/src/archive.rs | 103 +++-- client/src/main.rs | 126 +++++- .../up.sql | 6 +- .../up.sql | 6 +- src/db.rs | 397 +++++++++++++++++- src/game.rs | 19 +- src/lib.rs | 2 + src/models.rs | 114 ++++- src/schema.rs | 11 +- 10 files changed, 702 insertions(+), 83 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3d642ea..4b23850 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ uuid = { version = "^0.8", features = ["v4"] } twox-hash = "^1.6" thiserror = "^1.0" diesel = { version = "^1.4", features = ["sqlite"] } +diesel_migrations = "^1.4" dotenv = "^0.15" [dev-dependencies] diff --git a/client/src/archive.rs b/client/src/archive.rs index f365b91..eb89d29 100644 --- a/client/src/archive.rs +++ b/client/src/archive.rs @@ -1,5 +1,6 @@ use crate::utils::{self, ProjectDirError}; use log::{debug, info, trace, warn}; +use save_sync::db::{establish_connection, query::GameSaveQuery, Database}; use save_sync::game::{GameFile, GameSaveLocation}; use std::path::{Path, PathBuf}; use thiserror::Error; @@ -63,7 +64,9 @@ impl Archive { config_root: config_root.to_path_buf(), } } +} +impl Archive { /// Adds a path and its contents to the list of tracked game files /// /// TODO: Add note here about how GameSaveLocation, GameFile and tracking individual files rather than directories work @@ -74,7 +77,7 @@ impl Archive { /// # Examples /// ``` /// # use client::archive::Archive; - /// let mut archive = Archive::try_default().unwrap(); + /// let mut archive = Archive::try_default().expect("Failed to create an Archive"); /// let save_path = "/home/user/Documents/generic_company/generic_game/save_folder"; /// match archive.track_game(save_path) { /// Ok(_) => println!("Save Sync is now tracking {}", save_path), @@ -83,6 +86,9 @@ impl Archive { /// ``` pub fn track_game>(&mut self, path: P) -> Result<(), GameTrackError> { let game_save_loc = self.get_game_save_files(path, None)?; + + Self::write_game_save(&game_save_loc); + self.tracked_games.push(game_save_loc); Ok(()) } @@ -97,7 +103,7 @@ impl Archive { /// # Examples /// ``` /// # use client::archive::Archive; - /// let mut archive = Archive::try_default().unwrap(); + /// let mut archive = Archive::try_default().expect("Failed to create an Archive"); /// let save_path = "/home/user/Documents/generic_company/generic_game/save_folder"; /// let friendly_name = "Generic Game"; /// match archive.track_game_with_friendly(save_path, friendly_name) { @@ -109,6 +115,9 @@ impl Archive { P: AsRef, { let game_save_loc = self.get_game_save_files(path, Some(name))?; + + Self::write_game_save(&game_save_loc); + self.tracked_games.push(game_save_loc); Ok(()) } @@ -155,50 +164,50 @@ impl Archive { let friendly_name = friendly_name.map(|s| s.to_owned()); Ok(GameSaveLocation::new(path, game_files, friendly_name)) } +} - /// Removes a game from the list of tracked games - /// - /// Will fail if: - /// * The path provided isn't associated with any game - /// - /// # Arguments - /// * `path` - The path of the tracked game save location which will be dropped - /// - /// # Examples - /// ``` - /// # use client::archive::Archive; - /// let mut archive = Archive::try_default().unwrap(); - /// let drop_res = archive.drop_game("/home/user/Documents/generic_company/generic_game/save_folder"); - /// ``` - pub fn drop_game>(&mut self, path: P) -> Result<(), GameDropError> { - self.tracked_games - .retain(|game| game.original_path != path.as_ref()); - - // TODO: Remove backup copy of game save location on disk - Ok(()) +impl Archive { + pub fn drop_game>(path: P) -> Result, GameDropError> { + let conn = establish_connection(); + let query = GameSaveQuery::Path(path.as_ref()); + Ok(Database::drop_game_save(&conn, query)?) } - /// Removes a game from the list of tracked games using the game's friendly name - /// - /// Otherwise, is identical to [`Archive::drop_game`] - /// - /// # Arguments - /// * `name` - The friendly name of the tracked game save location which will be dropped - /// - /// # Examples - /// ``` - /// # use client::archive::Archive; - /// let mut archive = Archive::try_default().unwrap(); - /// let drop_res = archive.drop_game("raging_loop"); - /// ``` - pub fn drop_game_with_friendly(&mut self, name: &str) -> Result<(), GameDropError> { - self.tracked_games.retain(|game| match &game.friendly_name { - Some(f_name) => f_name != name, - None => false, - }); + pub fn drop_game_with_friendly(name: &str) -> Result, GameDropError> { + let conn = establish_connection(); + let query = GameSaveQuery::FriendlyName(name); + Ok(Database::drop_game_save(&conn, query)?) + } +} - // TODO: Remove backup copy of game save location on disk - Ok(()) +impl Archive { + pub fn get_game

(path: P) -> Result, GameGetError> + where + P: AsRef, + { + let conn = establish_connection(); + let query = GameSaveQuery::Path(path.as_ref()); + Ok(Database::get_game_save(&conn, query)?) + } + + pub fn get_game_with_friendly(name: &str) -> Result, GameGetError> { + let conn = establish_connection(); + let query = GameSaveQuery::FriendlyName(name); + Ok(Database::get_game_save(&conn, query)?) + } +} + +impl Archive { + pub fn get_all_games() -> Result>, GameGetError> { + let conn = establish_connection(); + Ok(Database::get_all_game_saves(&conn)?) + } +} + +impl Archive { + fn write_game_save(game_save_loc: &GameSaveLocation) -> Result<(), GameTrackError> { + let conn = establish_connection(); + Ok(Database::write_game_save(&conn, &game_save_loc)?) } } @@ -208,6 +217,8 @@ pub enum GameTrackError { IoError(#[from] std::io::Error), #[error("{0:?} is not a supported inode type (File System Object)")] UnknownFileSystemObject(PathBuf), // FIXME: Is there a better name for this? + #[error(transparent)] + DatabaseError(#[from] save_sync::db::DatabaseError), } #[derive(Error, Debug)] @@ -216,4 +227,12 @@ pub enum GameDropError { UnknownFriendlyName(String), #[error("Unable to find game with the path {0:?}")] UnknownPath(PathBuf), + #[error(transparent)] + DatabaseError(#[from] save_sync::db::DatabaseError), +} + +#[derive(Error, Debug)] +pub enum GameGetError { + #[error(transparent)] + DatabaseError(#[from] save_sync::db::DatabaseError), } diff --git a/client/src/main.rs b/client/src/main.rs index a62840b..8d85da5 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -3,7 +3,9 @@ use clap::{App, Arg, SubCommand}; use client::archive::Archive; use dotenv::dotenv; use log::{debug, info}; +use save_sync::db::{establish_connection, Database}; use std::path::Path; + fn main() { dotenv().ok(); env_logger::init(); @@ -32,29 +34,135 @@ fn main() { .help("A friendly name for a tracked file / directory"), ), ) + .subcommand( + SubCommand::with_name("info") + .arg( + Arg::with_name("path") + .value_name("PATH") + .takes_value(true) + .required_unless("friendly") + .index(1) + .help("The path of the game save"), + ) + .arg( + Arg::with_name("friendly") + .long("friendly") + .short("f") + .value_name("NAME") + .takes_value(true) + .help("The friendly name of the game save"), + ), + ) + .subcommand(SubCommand::with_name("list")) + .subcommand( + SubCommand::with_name("drop") + .arg( + Arg::with_name("path") + .value_name("PATH") + .takes_value(true) + .required_unless("friendly") + .index(1) + .help("The path of the files you no longer want to track"), + ) + .arg( + Arg::with_name("friendly") + .long("friendly") + .short("f") + .value_name("NAME") + .takes_value(true) + .help("The friendly name of the game save you no longer want tracked."), + ), + ) .get_matches(); match m.subcommand() { - ("track", Some(sub_m)) => track_path(sub_m), + ("track", Some(sub_m)) => track_save(sub_m), + ("drop", Some(sub_m)) => drop_save(sub_m), + ("info", Some(sub_m)) => tracked_save_info(sub_m), + ("list", Some(sub_m)) => list_tracked_saves(sub_m), _ => eprintln!("No valid subcommand / argument provided"), } } -fn track_path(matches: &ArgMatches) { - let path = Path::new(matches.value_of("path").unwrap()); - let mut archive = Archive::try_default().expect("Failed to create an Archive struct"); +fn track_save(matches: &ArgMatches) { + let path_str = matches + .value_of("path") + .expect("The path argument was not set despite it being required"); + let mut archive = Archive::try_default().expect("Failed to create a new Archive"); if let Some(f_name) = matches.value_of("friendly") { - info!("Name {} present for {:?}", f_name, path); + info!("Name {} present for {:?}", f_name, path_str); archive - .track_game_with_friendly(path, f_name) + .track_game_with_friendly(path_str, f_name) .expect("Archive failed to track Game Save Location") } else { - info!("No friendly name present for {:?}", path); + info!("No friendly name present for {:?}", path_str); archive - .track_game(path) + .track_game(path_str) .expect("Archive failed to track Game Save Location"); } - info!("Now Tracking: {:?}", path); + info!("Now Tracking: {:?}", path_str); +} + +fn tracked_save_info(matches: &ArgMatches) { + let maybe_game = if let Some(f_name) = matches.value_of("friendly") { + Archive::get_game_with_friendly(f_name).expect("Failed to get game save info from Archive") + } else { + // There is guaranteed to be a path given by the user + let path_str = matches + .value_of("path") + .expect("The path argument was not set despite it being required"); + Archive::get_game(path_str).expect("Failed to get game save info from Archive") + }; + + let game = maybe_game.expect("No tracked game save found"); + + if let Some(name) = game.friendly_name { + println!("Friendly Name: {}", name); + } else { + println!("Friendly Name: None"); + } + + println!("Original Path: {:?}", game.original_path); + println!("UUID: {:?}", game.uuid); + println!("---\nFiles:"); + + for file in game.files { + println!("Path: {:?}", file.original_path); + println!("Hash: {}", file.hash); + println!(); + } +} + +fn list_tracked_saves(_matches: &ArgMatches) { + let games = Archive::get_all_games() + .expect("Failed to get all Games from the Archive") + .expect("There are no tracked Games"); + + for game in games { + if let Some(name) = game.friendly_name { + print!("[{}] ", name); + } + println!("{:?}", game.original_path); + println!("UUID: {:?}", game.uuid); + println!("---"); + } +} + +fn drop_save(matches: &ArgMatches) { + let maybe_compromised = if let Some(name) = matches.value_of("friendly") { + Archive::drop_game_with_friendly(name).expect("Archive failed to delete from database") + } else { + let path_str = matches + .value_of("path") + .expect("The path argument was not set despite it being required"); + + Archive::drop_game(path_str).expect("Archive failed to delete from database") + }; + + match maybe_compromised { + Some(()) => println!("Game successfully dropped from the list"), + None => panic!("Database Invariant broken. Database is corrupted."), + } } diff --git a/migrations/2021-03-02-022949_create_game_file_table/up.sql b/migrations/2021-03-02-022949_create_game_file_table/up.sql index b4d4faa..d98b898 100644 --- a/migrations/2021-03-02-022949_create_game_file_table/up.sql +++ b/migrations/2021-03-02-022949_create_game_file_table/up.sql @@ -1,8 +1,8 @@ -- Your SQL goes here CREATE TABLE game_file ( - id INTEGER PRIMARY KEY, - original_path BLOB NOT NULL, -- TEXT assumes a Unicode Encoding - file_hash INTEGER NOT NULL, -- u64, but that's not + id INTEGER PRIMARY KEY NOT NULL, + original_path TEXT NOT NULL, -- TEXT assumes a Unicode Encoding + file_hash BLOB NOT NULL, game_save_id INTEGER NOT NULL, FOREIGN KEY (game_save_id) REFERENCES game_save_location (id) ) \ No newline at end of file diff --git a/migrations/2021-03-02-025744_create_game_save_loc_table/up.sql b/migrations/2021-03-02-025744_create_game_save_loc_table/up.sql index 2efd290..5b4d873 100644 --- a/migrations/2021-03-02-025744_create_game_save_loc_table/up.sql +++ b/migrations/2021-03-02-025744_create_game_save_loc_table/up.sql @@ -1,7 +1,7 @@ -- Your SQL goes here CREATE TABLE game_save_location ( - id INTEGER PRIMARY KEY, - friendly_name TEXT -- This can be null - original_PATH BLOB NOT NULL, + id INTEGER PRIMARY KEY NOT NULL, + friendly_name TEXT, -- This can be null + original_path TEXT NOT NULL, uuid BLOB NOT NULL ) \ No newline at end of file diff --git a/src/db.rs b/src/db.rs index 5a8476e..117a464 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,7 +1,16 @@ +use super::game::{GameFile, GameSaveLocation}; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; +use diesel_migrations::embed_migrations; use dotenv::dotenv; -use std::env; +use query::{GameFileQuery, GameSaveQuery}; +use std::{env, path::PathBuf}; +use thiserror::Error; + +pub type Result = std::result::Result; +pub type ResultantOption = std::result::Result, DatabaseError>; + +embed_migrations!("./migrations"); /// Establishes a DB Connection with a Sqlite Database /// @@ -21,5 +30,389 @@ pub fn establish_connection() -> SqliteConnection { // or have establish_connection return a Result with a thiserror enum let db_url = env::var("DATABASE_URL").expect("$DATABASE_URL was not set"); - SqliteConnection::establish(&db_url).expect(&format!("Error connecting to {}", db_url)) + let conn = SqliteConnection::establish(&db_url) + .unwrap_or_else(|_| panic!("Error connecting to {}", db_url)); + + // Perform all Migrations on Database + embedded_migrations::run(&conn).expect("Failed to run migrations on sqlite3 database"); + conn +} + +/// The Database struct contains methods which interact with the Sqlite3 backend +#[derive(Debug, Clone, Copy)] +pub struct Database; + +// Add GameFile Implementations +impl Database { + /// Writes a GameFile to the Database + /// + /// # Arguments + /// * `conn` - A reference to a [`SqliteConnection`] + /// * `file` - A reference to the [`GameFile`] that will be Saved + /// * `needle` - The ID of the [`GameSaveLocation`] that the [`GameFile`] belongs to. + /// + /// # Examples + /// ``` + /// // Create the GameFile + /// # use save_sync::game::GameFile; + /// # use save_sync::db::{establish_connection, Database}; + /// let path = "/home/user/Documents/some_company/some_game/saves/save01.sav"; + /// match GameFile::new(path) { + /// Ok(file) => { + /// // TODO: Better explain how we can get the id of a GameSaveLocation + /// let conn = establish_connection(); + /// let id = 1; + /// + /// match Database::write_game_file(&conn, &file, id) { + /// Ok(_) => println!("Writing a File was a Success"), + /// Err(err) => eprintln!("Database Error: {}", err), + /// }; + /// } + /// Err(err) => { eprintln!("Error while attempting to calculate the hash of {}", path)} + /// }; + /// ``` + pub fn write_game_file(conn: &SqliteConnection, file: &GameFile, needle: i32) -> Result<()> { + use super::models::NewGameFile; + use super::schema::game_file; + use DatabaseError::InvalidPathError; + + let hash_bytes: [u8; 8] = file.hash.to_be_bytes(); + let path = &file.original_path; + let path_str = path + .to_str() + .ok_or_else(|| InvalidPathError(path.clone()))?; + + let new_game_file = NewGameFile { + original_path: path_str, + file_hash: &hash_bytes, + game_save_id: needle, + }; + + diesel::insert_into(game_file::table) + .values(&new_game_file) + .execute(conn)?; + + Ok(()) + } +} + +// Get GameFile Implementations +impl Database { + pub fn get_game_file( + conn: &SqliteConnection, + query: GameFileQuery, + ) -> ResultantOption { + use super::models::DbGameFile; + use super::schema::game_file::dsl::{file_hash, game_file, original_path}; + use DatabaseError::InvalidPathError; + + let game_files = match query { + GameFileQuery::Hash(hash) => { + let hash_slice: &[u8] = &hash.to_be_bytes(); + game_file + .filter(file_hash.eq(hash_slice)) + .load::(conn)? + } + GameFileQuery::Path(path) => { + let path_str = path + .to_str() + .ok_or_else(|| InvalidPathError(path.to_path_buf()))?; + game_file + .filter(original_path.eq(path_str)) + .load::(conn)? + } + }; + + let maybe_game_files = if game_files.len() == 1 { + Some((&game_files[0]).into()) + } else { + None + }; + + Ok(maybe_game_files) + } +} + +// Get GameFiles Implementations +impl Database { + pub fn get_game_files( + conn: &SqliteConnection, + query: GameSaveQuery, + ) -> ResultantOption> { + match query { + GameSaveQuery::Id(id) => Self::_get_game_files(conn, id), + _ => match Self::get_game_save_id(conn, query)? { + Some(id) => Self::_get_game_files(conn, id), + None => Ok(None), + }, + } + } + + fn _get_game_files(conn: &SqliteConnection, needle: i32) -> ResultantOption> { + use super::models::DbGameFile; + use super::schema::game_file::dsl::{game_file, game_save_id}; + + let game_files = game_file + .filter(game_save_id.eq(needle)) + .load::(conn)?; + + let maybe_game_files = if !game_files.is_empty() { + Some(game_files.iter().map(GameFile::from).collect()) + } else { + None + }; + + Ok(maybe_game_files) + } +} + +// Drop GameFile Implementations +impl Database { + fn drop_game_file(conn: &SqliteConnection, query: GameFileQuery) -> ResultantOption<()> { + use super::schema::game_file::dsl::{file_hash, game_file, original_path}; + use DatabaseError::InvalidPathError; + + let num_deleted = match query { + GameFileQuery::Hash(hash) => { + let hash_bytes: &[u8] = &hash.to_be_bytes(); + let expr = game_file.filter(file_hash.eq(hash_bytes)); + diesel::delete(expr).execute(conn)? + } + GameFileQuery::Path(path) => { + let path_str = path + .to_str() + .ok_or_else(|| InvalidPathError(path.to_path_buf()))?; + let expr = game_file.filter(original_path.eq(path_str)); + diesel::delete(expr).execute(conn)? + } + }; + + Ok(if num_deleted == 1 { Some(()) } else { None }) + } +} + +// Add GameSaveLocation Implementations +impl Database { + /// Writes a GameSaveLocation to the Database + /// + /// # Arguments + /// * `conn` - A reference to a [`SqliteConnection`] + /// * `save_loc` - A reference to a [`GameSaveLocation`] + /// + /// # Examples + /// ``` + /// # use save_sync::game::{GameSaveLocation, GameFile}; + /// # use save_sync::db::{Database, establish_connection}; + /// # use save_sync::db::query::GameSaveQuery; + /// // Create the GameSaveLocation + /// let path = "/home/user/Documents/some_company/some_game/saves"; + /// let files: Vec = Vec::new(); + /// let friendly_name = "Some Game".to_string(); + /// let game_save_location = GameSaveLocation::new(path, files, Some(friendly_name)); + /// + /// // Write the GameSaveLocation to the Database + /// let conn = establish_connection(); + /// Database::write_game_save(&conn, &game_save_location); + /// # Database::drop_game_save(&conn, GameSaveQuery::Uuid(game_save_location.uuid)); // Clean up + /// ``` + pub fn write_game_save(conn: &SqliteConnection, save_loc: &GameSaveLocation) -> Result<()> { + // Write Game Save Location to Database + use super::models::NewGameSaveLocation; + use super::schema::game_save_location; + use DatabaseEntry::Location; + use DatabaseError::{InvalidPathError, MissingDatabaseEntry}; + + let path = &save_loc.original_path; + let original_path = path + .to_str() + .ok_or_else(|| InvalidPathError(path.clone()))?; + + let new_game_save = NewGameSaveLocation { + friendly_name: save_loc.friendly_name.as_deref(), + original_path, + uuid: save_loc.uuid.as_bytes(), + }; + + diesel::insert_into(game_save_location::table) + .values(&new_game_save) + .execute(conn)?; + + // Get the ID of the Game Save in the database + let maybe_id = Self::get_game_save_id(conn, GameSaveQuery::Uuid(save_loc.uuid))?; + + match maybe_id { + Some(id) => { + // Write all the GameFiles into the database + for game_file in &save_loc.files { + Self::write_game_file(conn, game_file, id)?; + } + } + None => return Err(MissingDatabaseEntry(Location(save_loc.clone()))), + } + + Ok(()) + } +} + +// Get GameSaveLocation Implementations +impl Database { + pub fn get_game_save( + conn: &SqliteConnection, + query: GameSaveQuery, + ) -> ResultantOption { + use super::models::DbGameSaveLocation; + use super::schema::game_save_location::dsl::{ + friendly_name, game_save_location, id, original_path, uuid, + }; + use DatabaseError::InvalidPathError; + + let save_locs = match query { + GameSaveQuery::Id(needle) => game_save_location + .filter(id.eq(needle)) + .load::(conn)?, + GameSaveQuery::FriendlyName(name) => game_save_location + .filter(friendly_name.eq(name)) + .load::(conn)?, + GameSaveQuery::Path(path) => { + let path_str = path + .to_str() + .ok_or_else(|| InvalidPathError(path.to_path_buf()))?; + game_save_location + .filter(original_path.eq(path_str)) + .load::(conn)? + } + GameSaveQuery::Uuid(uuid_value) => { + let uuid_bytes: &[u8] = uuid_value.as_bytes(); + game_save_location + .filter(uuid.eq(uuid_bytes)) + .load::(conn)? + } + }; + + let maybe_save_loc = if save_locs.len() == 1 { + Some((&save_locs[0]).into()) + } else { + None + }; + + Ok(maybe_save_loc) + } + + pub fn get_game_save_id(conn: &SqliteConnection, query: GameSaveQuery) -> ResultantOption { + use super::schema::game_save_location::dsl::{ + friendly_name, game_save_location, id, original_path, uuid, + }; + use DatabaseError::InvalidPathError; + + let ids = match query { + GameSaveQuery::Id(id_value) => vec![id_value], // Why? + GameSaveQuery::Uuid(uuid_value) => { + let uuid_bytes: &[u8] = uuid_value.as_bytes(); + game_save_location + .select(id) + .filter(uuid.eq(uuid_bytes)) + .load::(conn)? + } + GameSaveQuery::FriendlyName(name) => game_save_location + .select(id) + .filter(friendly_name.eq(name)) + .load::(conn)?, + GameSaveQuery::Path(path) => { + let path_str = path + .to_str() + .ok_or_else(|| InvalidPathError(path.to_path_buf()))?; + game_save_location + .select(id) + .filter(original_path.eq(path_str)) + .load::(conn)? + } + }; + + // FIXME: Is there are more ergonomic way of doing this? + Ok(if ids.len() == 1 { Some(ids[0]) } else { None }) + } + + pub fn get_all_game_saves(conn: &SqliteConnection) -> ResultantOption> { + use super::models::DbGameSaveLocation; + use super::schema::game_save_location::dsl::game_save_location; + + let db_save_locs = game_save_location + .load::(conn)? + .iter() + .map(GameSaveLocation::from) + .collect(); + + Ok(Some(db_save_locs)) + } +} + +// Drop GameSaveLocation Implementations +impl Database { + pub fn drop_game_save(conn: &SqliteConnection, query: GameSaveQuery) -> ResultantOption<()> { + use super::schema::game_save_location::dsl::{ + friendly_name, game_save_location, id, original_path, uuid, + }; + use DatabaseError::InvalidPathError; + + let num_deleted = match query { + GameSaveQuery::Id(needle) => { + let expr = game_save_location.filter(id.eq(needle)); + diesel::delete(expr).execute(conn)? + } + GameSaveQuery::FriendlyName(name) => { + let expr = game_save_location.filter(friendly_name.eq(name)); + diesel::delete(expr).execute(conn)? + } + GameSaveQuery::Path(path) => { + let path_str = path + .to_str() + .ok_or_else(|| InvalidPathError(path.to_path_buf()))?; + let expr = game_save_location.filter(original_path.eq(path_str)); + diesel::delete(expr).execute(conn)? + } + GameSaveQuery::Uuid(uuid_value) => { + let uuid_bytes: &[u8] = uuid_value.as_bytes(); + let expr = game_save_location.filter(uuid.eq(uuid_bytes)); + diesel::delete(expr).execute(conn)? + } + }; + + // FIXME: Is there are more ergonomic way of doing this? + Ok(if num_deleted == 1 { Some(()) } else { None }) + } +} + +#[derive(Debug, Error)] +pub enum DatabaseError { + #[error("The path {0:?} can not be converted to a UTF-8 String")] + InvalidPathError(PathBuf), + #[error(transparent)] + OrmError(#[from] diesel::result::Error), + #[error("Expected {0:?} to be present in the DB but it was not")] + MissingDatabaseEntry(DatabaseEntry), +} + +#[derive(Debug, Clone)] +pub enum DatabaseEntry { + Location(GameSaveLocation), + File(GameFile), +} + +pub mod query { + use std::path::Path; + use uuid::Uuid; + + #[derive(Debug, Clone, Copy)] + pub enum GameSaveQuery<'a> { + Id(i32), + Uuid(Uuid), + FriendlyName(&'a str), + Path(&'a Path), + } + + #[derive(Debug, Clone, Copy)] + pub enum GameFileQuery<'a> { + Hash(u64), + Path(&'a Path), + } } diff --git a/src/game.rs b/src/game.rs index 6a37593..286d40c 100644 --- a/src/game.rs +++ b/src/game.rs @@ -14,8 +14,8 @@ const XXHASH64_SEED: u64 = 1337; pub struct GameSaveLocation { pub friendly_name: Option, pub original_path: PathBuf, - files: Vec, - uuid: Uuid, + pub files: Vec, + pub uuid: Uuid, } impl GameSaveLocation { @@ -69,7 +69,7 @@ impl GameFile { /// # Examples /// ``` /// # use save_sync::game::GameFile; - /// let path = "/home/user/Documents/some_company/some_game/saves"; + /// let path = "/home/user/Documents/some_company/some_game/saves/save01.sav"; /// match GameFile::new(path) { /// Ok(_) => { /* Do something with the file */ } /// Err(err) => { eprintln!("Error while attempting to calculate the hash of {}", path)} @@ -99,19 +99,6 @@ pub enum GameFileError { IoError(#[from] std::io::Error), } -#[derive(Debug, Default)] -struct BackupPath { - inner: Option, -} - -impl BackupPath { - fn new>(path: P) -> Self { - Self { - inner: Some(path.as_ref().to_path_buf()), - } - } -} - struct HashWriter(T); impl Write for HashWriter { diff --git a/src/lib.rs b/src/lib.rs index a94397d..c62b49c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ #[macro_use] extern crate diesel; +#[macro_use] +extern crate diesel_migrations; pub mod db; pub mod game; diff --git a/src/models.rs b/src/models.rs index 91ad98a..7d50605 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,8 +1,116 @@ +use super::db::query::GameSaveQuery; +use super::db::Database; +use super::game::{GameFile, GameSaveLocation}; +use super::schema::{game_file, game_save_location}; +use std::convert::TryInto; use std::path::PathBuf; +use uuid::Uuid; #[derive(Queryable)] -pub struct GameFile { +pub struct DbGameFile { pub id: i32, - pub original_path: PathBuf, - pub file_hash: u64, + pub original_path: String, + pub file_hash: Vec, + pub game_save_id: i32, +} + +impl From for GameFile { + fn from(db_file: DbGameFile) -> Self { + let original_path = PathBuf::from(db_file.original_path); + let hash_bytes: [u8; 8] = db_file + .file_hash + .try_into() + .expect("GameFile hash from DB was not 8 bytes long"); + let hash = u64::from_be_bytes(hash_bytes); + + Self { + original_path, + hash, + } + } +} + +impl From<&DbGameFile> for GameFile { + fn from(db_file: &DbGameFile) -> Self { + let original_path = PathBuf::from(&db_file.original_path); + let buf: &[u8] = db_file.file_hash.as_ref(); + let hash_bytes: [u8; 8] = buf + .try_into() + .expect("GameFile hash from DB was not 8 bytes long"); + let hash = u64::from_be_bytes(hash_bytes); + + Self { + original_path, + hash, + } + } +} + +#[derive(Insertable)] +#[table_name = "game_file"] +pub struct NewGameFile<'a> { + pub original_path: &'a str, + pub file_hash: &'a [u8], + pub game_save_id: i32, +} + +#[derive(Queryable)] +pub struct DbGameSaveLocation { + pub id: i32, + pub friendly_name: Option, + pub original_path: String, + pub uuid: Vec, +} + +impl From for GameSaveLocation { + fn from(db_save_loc: DbGameSaveLocation) -> Self { + // FIXME: This makes .into() and ::from() rather resource intensive + // This isn't that intuitive. It might be best to abandon the From<> traits here + // and go for dedicated methods that express how much the database is read from here + let original_path = PathBuf::from(db_save_loc.original_path); + + let uuid = + Uuid::from_slice(&db_save_loc.uuid).expect("UUIDv4 from Database was not 16bytes long"); + let conn = super::db::establish_connection(); + let files = Database::get_game_files(&conn, GameSaveQuery::Id(db_save_loc.id)) + .expect("Failed to Interact w/ the Database") + .expect("Turn this into a TryFrom Please"); + + Self { + friendly_name: db_save_loc.friendly_name, + original_path, + files, + uuid, + } + } +} + +impl From<&DbGameSaveLocation> for GameSaveLocation { + fn from(db_save_loc: &DbGameSaveLocation) -> Self { + // FIXME: See From for GameSaveLocation + let original_path = PathBuf::from(&db_save_loc.original_path); + + // FIXME: Is it reasonable to assume that this will **never** fail? + let uuid = + Uuid::from_slice(&db_save_loc.uuid).expect("UUIDv4 from Database was not 16bytes long"); + let conn = super::db::establish_connection(); + let files = Database::get_game_files(&conn, GameSaveQuery::Id(db_save_loc.id)) + .expect("Failed to interact w/ the Database") + .expect("Turn this into a TryFrom please"); + + Self { + friendly_name: db_save_loc.friendly_name.clone(), + original_path, + files, + uuid, + } + } +} + +#[derive(Insertable)] +#[table_name = "game_save_location"] +pub struct NewGameSaveLocation<'a> { + pub friendly_name: Option<&'a str>, + pub original_path: &'a str, + pub uuid: &'a [u8], } diff --git a/src/schema.rs b/src/schema.rs index 549c02f..ba9d3bc 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,16 +1,17 @@ table! { game_file (id) { - id -> Nullable, - original_path -> Binary, - file_hash -> Integer, + id -> Integer, + original_path -> Text, + file_hash -> Binary, game_save_id -> Integer, } } table! { game_save_location (id) { - id -> Nullable, - friendly_name -> Text, + id -> Integer, + friendly_name -> Nullable, + original_path -> Text, uuid -> Binary, } }