diff --git a/Cargo.toml b/Cargo.toml index ff551da..630bd81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,5 +13,7 @@ categories = ["command-line-utilities"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.102" json = "0.12.4" +rusqlite = { version = "0.39.0", features = ["bundled"] } seahorse = "2.1.0" diff --git a/src/actions.rs b/src/actions.rs index 93a72a0..84e33be 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,109 +1,114 @@ -use std::fs::File; -use std::io::Write; use std::path::Path; -use std::process::exit; -use json::JsonValue; -use seahorse::Context; +use seahorse::{ActionError, ActionResult, Context}; -use crate::json_object::{get_json_object_or_create, set_json_object}; -use crate::{config::get_config_path, error::invalid}; +use crate::config::get_db_path; +use crate::error::{invalid, to_action_error}; +use crate::storage::sqlite::SQLiteStore; +use crate::storage::{self, Store, StoreValue}; -pub fn init_action(c: &Context) { - let config_path = get_config_path(); +pub fn init_action(_c: &Context) -> ActionResult { + let config_path = get_db_path(); let path = Path::new(&config_path); if path.exists() { println!("config file already exists"); - } else { - clear_action(c); } + + SQLiteStore::from_path(path); + + Ok(()) } -pub fn list_action(c: &Context) { - let conf = get_json_object_or_create(c.bool_flag("force-create")); +pub fn list_action(_c: &Context) -> ActionResult { + let store = storage::load_storage(); - for (key, value) in conf.entries() { + for (key, value) in store.all().map_err(to_action_error)?.iter() { println!("{}\t{}", key, value); } + + Ok(()) } -pub fn clear_action(_c: &Context) { - let mut file = File::create(get_config_path()).unwrap(); - write!(file, "{}", "{}").unwrap(); - println!("cleared config file at '{:?}'", get_config_path()); +pub fn clear_action(_c: &Context) -> ActionResult { + let mut store = storage::load_storage(); + + let count = store.clear().map_err(to_action_error)?; + + println!("removed {} keys from store", count); + + Ok(()) } -pub fn get_action(c: &Context) { +pub fn get_action(c: &Context) -> ActionResult { if c.args.len() != 1 { - return invalid("command"); + return Err(invalid("command")); } - let conf = get_json_object_or_create(c.bool_flag("force-create")); - let key = c.args.get(0); + let key = c.args.get(0).to_owned(); let Some(key) = key else { - return invalid("key"); + return Err(invalid("key")); }; - if conf.has_key(&key) { - println!("{}", conf[key]); - return; + let store = storage::load_storage(); + + let value = store.get(key).map_err(to_action_error)?; + + match value { + Some(v) => { + println!("{}", v) + } + None => { + if c.bool_flag("ignore_null") { + println!(); + } else { + return Err(ActionError { + message: format!("could not find key '{}'", key), + }); + } + } } - if c.bool_flag("ignore-null") { - println!(); - } else { - eprintln!("could not find key '{}'", key); - exit(1); - } + Ok(()) } -pub fn set_action(c: &Context) { +pub fn set_action(c: &Context) -> ActionResult { if c.args.len() != 2 { - return invalid("command"); + return Err(invalid("command")); } - let mut conf = get_json_object_or_create(c.bool_flag("force-create")); - let Some(key) = c.args.get(0) else { - return invalid("key"); + return Err(invalid("key")); }; let Some(value_str) = c.args.get(1) else { - return invalid("value"); + return Err(invalid("value")); }; - let json_value = JsonValue::from(value_str.as_str()); - let value = json_value.as_str().unwrap(); + let mut store = storage::load_storage(); - if conf.has_key(key) { - conf.remove(key); - } + let value = StoreValue::Value(value_str.to_owned()); + store.set(key, value.clone()).map_err(to_action_error)?; - conf.insert(key, value).unwrap(); + println!("'{}' -> '{}'", key, value); - match set_json_object(conf) { - Ok(_) => println!("updated config file"), - Err(err) => eprintln!("{}", err), - } + Ok(()) } -pub fn remove_action(c: &Context) { - let mut conf = get_json_object_or_create(c.bool_flag("force-create")); +pub fn remove_action(c: &Context) -> ActionResult { let Some(key) = c.args.get(0) else { - return invalid("key"); + return Err(invalid("key")); }; - if !conf.has_key(&key) { - println!("key '{}' was not found", key); - return; - } - - conf.remove(&key); + let mut store = storage::load_storage(); - match set_json_object(conf) { - Ok(_) => println!("updated config file"), - Err(err) => eprintln!("{}", err), + match store.remove(key).map_err(to_action_error)? { + Some(value) => println!("{}\t{}", key, value), + None => { + println!("key '{}' was not found", key); + } } + + Ok(()) } diff --git a/src/commands.rs b/src/commands.rs index 7d73dee..30696d2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -10,7 +10,7 @@ pub fn init() -> Command { .description("inits config file") .alias("i") .usage(format!("{} init", env!("CARGO_PKG_NAME"))) - .action(init_action) + .action_with_result(init_action) } pub fn list() -> Command { @@ -18,7 +18,7 @@ pub fn list() -> Command { .description("list all keys and values") .alias("l") .usage(format!("{} list", env!("CARGO_PKG_NAME"))) - .action(list_action) + .action_with_result(list_action) .flag(force_create()) } @@ -27,7 +27,7 @@ pub fn clear() -> Command { .description("clear your config file") .alias("c") .usage(format!("{} clear", env!("CARGO_PKG_NAME"))) - .action(clear_action) + .action_with_result(clear_action) } pub fn remove_value() -> Command { @@ -35,7 +35,7 @@ pub fn remove_value() -> Command { .description("remove a value") .alias("r") .usage(format!("{} remove foo", env!("CARGO_PKG_NAME"))) - .action(remove_action) + .action_with_result(remove_action) } pub fn get_value() -> Command { @@ -43,7 +43,7 @@ pub fn get_value() -> Command { .description("get a value") .alias("g") .usage(format!("{} get foo", env!("CARGO_PKG_NAME"))) - .action(get_action) + .action_with_result(get_action) .flag(ignore_null()) .flag(force_create()) } @@ -53,6 +53,6 @@ pub fn set_value() -> Command { .description("set a value") .alias("s") .usage(format!("{} set foo bar", env!("CARGO_PKG_NAME"))) - .action(set_action) + .action_with_result(set_action) .flag(force_create()) } diff --git a/src/config.rs b/src/config.rs index 7aa09c1..52e8660 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,13 @@ use std::{env::home_dir, path::PathBuf}; +pub fn get_config_folder_path() -> PathBuf { + return home_dir().expect("couldn't find home directory"); +} + pub fn get_config_path() -> PathBuf { - let home_folder = home_dir().expect("couldn't find home directory"); - let path = home_folder.join(".cfs.json"); + return get_config_folder_path().join(".cfs.json"); +} - return path; +pub fn get_db_path() -> PathBuf { + return get_config_folder_path().join(".cfs.db"); } diff --git a/src/error.rs b/src/error.rs index cacf443..f23f60a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,17 @@ -use std::process::exit; +use seahorse::ActionError; -pub fn invalid(cause: &str) { - eprintln!("invalid {}. get help by running `conf set --help`", cause); - exit(1); +pub fn invalid(cause: &str) -> ActionError { + ActionError { + message: format!( + "invalid {}. get help by running `{} --help`", + cause, + env!("CARGO_PKG_NAME") + ), + } +} + +pub fn to_action_error(err: anyhow::Error) -> ActionError { + return ActionError { + message: err.to_string(), + }; } diff --git a/src/json_object.rs b/src/json_object.rs deleted file mode 100644 index 44a948c..0000000 --- a/src/json_object.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::fs::{read_to_string, File}; -use std::io; -use std::io::Write; -use std::process::exit; - -use json::JsonValue; - -use crate::config::get_config_path; - -pub fn get_json_object_or_create(force_create: bool) -> JsonValue { - let path_exists = &get_config_path().exists(); - - if force_create && !path_exists { - let mut file = File::create(get_config_path()).unwrap(); - write!(file, "{}", "{}").unwrap(); - } - - get_json_object() -} - -pub fn get_json_object() -> JsonValue { - let path = get_config_path(); - - if !path.exists() { - eprintln!("config file does not exist at '{:?}'", &path); - exit(1); - } - - let json = json::parse(&*read_to_string(&path).unwrap()).unwrap(); - - if !json.is_object() { - eprintln!("config file is not a JSON file ('{:?}')", &path); - exit(1); - } - - return json; -} - -pub fn set_json_object(json: JsonValue) -> io::Result<()> { - let mut file = File::create(get_config_path())?; - let json_string = json::stringify_pretty(json, 2); - write!(file, "{}", json_string)?; - - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index b6f78b4..1c46afe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ -use std::{env, io}; +use std::{env, process::exit}; -use seahorse::App; +use seahorse::{ActionResult, App}; use crate::commands::{clear, get_value, init, list, remove_value, set_value}; @@ -9,9 +9,9 @@ mod commands; mod config; mod error; mod flags; -mod json_object; +mod storage; -fn main() -> io::Result<()> { +fn main() -> ActionResult { let args: Vec = env::args().collect(); let app = App::new(env!("CARGO_PKG_NAME")) .description(env!("CARGO_PKG_DESCRIPTION")) @@ -25,7 +25,13 @@ fn main() -> io::Result<()> { .command(remove_value()) .command(clear()); - app.run(args); + match app.run_with_result(args) { + Ok(_) => (), + Err(action_error) => { + eprintln!("{}", action_error.message); + exit(1) + } + }; Ok(()) } diff --git a/src/storage/json.rs b/src/storage/json.rs new file mode 100644 index 0000000..b3a8f6a --- /dev/null +++ b/src/storage/json.rs @@ -0,0 +1,110 @@ +use anyhow::Result; + +use std::fs::{read_to_string, File}; +use std::io; +use std::io::Write; +use std::process::exit; + +use json::JsonValue; + +use crate::config::get_config_path; +use crate::storage::{Store, StoreValue}; + +pub fn init_store(force_create: bool) -> JsonValue { + let path = get_config_path(); + + if !path.exists() && force_create { + let mut file = File::create(get_config_path()).unwrap(); + write!(file, "{}", "{}").unwrap(); + } else if !path.exists() { + eprintln!("config file does not exist at '{:?}'", &path); + exit(1); + } + + let json = json::parse(&read_to_string(&path).unwrap()).unwrap(); + + if !json.is_object() { + eprintln!("config file is not a JSON file ('{:?}')", &path); + exit(1); + } + + json +} + +#[derive(Clone, Debug)] +pub struct JSONStore { + store: JsonValue, +} + +impl JSONStore { + pub fn new() -> Self { + return Self { + store: init_store(false), + }; + } + + pub fn with_force_create(force_create: bool) -> Self { + return Self { + store: init_store(force_create), + }; + } + + fn save_store(&mut self) -> Result<(), io::Error> { + let mut file = File::create(get_config_path())?; + + let json_string = json::stringify_pretty(self.store.clone(), 2); + + write!(file, "{}", json_string)?; + + Ok(()) + } +} + +impl Store for JSONStore { + fn all(&self) -> Result> { + Ok( + self + .store + .entries() + .map(|(key, value)| (key.to_owned(), value.into())) + .collect(), + ) + } + + fn get(&self, key: &str) -> Result> { + if !self.store.has_key(key) { + return Ok(None); + } + + Ok(Some(self.store[key].clone().into())) + } + + fn set(&mut self, key: &str, value: StoreValue) -> Result { + self.store.insert(key, value.clone()).unwrap(); + + self.save_store().unwrap(); + + Ok(value) + } + + fn remove(&mut self, key: &str) -> Result> { + if !self.store.has_key(key) { + return Ok(None); + } + + let value = self.store.remove(key); + + self.save_store().unwrap(); + + return Ok(Some(value.into())); + } + + fn clear(&mut self) -> Result { + let len = self.store.len(); + self.store.clear(); + + self.save_store().unwrap(); + + return Ok(len); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..ab323dc --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,24 @@ +use anyhow::Result; + +pub mod json; +pub mod sqlite; +mod value; + +pub use value::StoreValue; + +use crate::config::get_db_path; + +pub trait Store { + fn all(&self) -> Result>; + + fn get(&self, key: &str) -> Result>; + fn set(&mut self, key: &str, value: StoreValue) -> Result; + fn remove(&mut self, key: &str) -> Result>; + + fn clear(&mut self) -> Result; +} + +//TODO: Change STORE Based on config. +pub fn load_storage() -> impl Store { + return sqlite::SQLiteStore::from_path(get_db_path()); +} diff --git a/src/storage/sqlite/mod.rs b/src/storage/sqlite/mod.rs new file mode 100644 index 0000000..773a1de --- /dev/null +++ b/src/storage/sqlite/mod.rs @@ -0,0 +1,87 @@ +use anyhow::{anyhow, Result}; +use std::path::Path; + +use rusqlite::OptionalExtension as _; + +use crate::storage::{Store, StoreValue}; + +#[derive(Debug)] +pub struct SQLiteStore { + connection: rusqlite::Connection, +} + +impl SQLiteStore { + pub fn from_path>(path: P) -> Self { + let conn = rusqlite::Connection::open(path).expect("To Open SQLite DB"); + + conn + .execute_batch(include_str!("schema.sql")) + .expect("To Create DB"); + + Self { connection: conn } + } +} + +impl Store for SQLiteStore { + fn all(&self) -> Result> { + let mut query = self.connection.prepare("SELECT key,value from KV")?; + + let values = query + .query_map([], |row| Ok((row.get(0)?, StoreValue::Value(row.get(1)?))))? + .collect::, _>>()?; + + Ok(values) + } + + fn get(&self, key: &str) -> Result> { + let query = self + .connection + .query_row( + "SELECT key,value from KV where key = ?1 LIMIT 1", + [key], + |row| Ok(StoreValue::Value(row.get(1)?)), + ) + .optional()?; + + Ok(query) + } + + fn set(&mut self, key: &str, value: StoreValue) -> Result { + let StoreValue::Value(value) = value else { + return Err(anyhow!( + "Invalid value passed into SQLiteStore GET [{}]", + value + )); + }; + + self.connection.execute( + "INSERT INTO KV VALUES(NULL,?1,?2) ON CONFLICT(key) DO UPDATE SET value = ?2", + [key, &value], + )?; + + Ok(StoreValue::Value(value)) + } + + fn remove(&mut self, key: &str) -> Result> { + let value = self.get(key)?; + + let Some(value) = value else { + return Ok(None); + }; + + let query = self + .connection + .execute("DELETE FROM KV where key = ?1", [key])?; + + if query == 0 { + panic!("Deleted 0 Rows when trying to delete Value from Store") + } + + Ok(Some(value)) + } + + fn clear(&mut self) -> Result { + let deleted = self.connection.execute("DELETE FROM KV", [])?; + Ok(deleted) + } +} diff --git a/src/storage/sqlite/schema.sql b/src/storage/sqlite/schema.sql new file mode 100644 index 0000000..37d54a7 --- /dev/null +++ b/src/storage/sqlite/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS KV ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL, + value TEXT NOT NULL + ); + +CREATE UNIQUE INDEX IF NOT EXISTS kv_keys ON KV (key); diff --git a/src/storage/value.rs b/src/storage/value.rs new file mode 100644 index 0000000..d69a683 --- /dev/null +++ b/src/storage/value.rs @@ -0,0 +1,60 @@ +use std::fmt::Display; + +use json::JsonValue; + +#[derive(Debug, Clone)] +pub enum StoreValue { + Value(String), + List(Vec), +} + +impl Display for StoreValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StoreValue::Value(v) => write!(f, "{}", v), + StoreValue::List(items) => { + let mut array_string = String::new(); + for i in items.iter() { + array_string.push_str(&format!("{},", i)); + } + + write!(f, "[{}]", array_string) + } + } + } +} + +impl From for JsonValue { + fn from(value: StoreValue) -> Self { + match value { + StoreValue::Value(string) => JsonValue::String(string), + StoreValue::List(items) => { + JsonValue::Array(items.into_iter().map(|i| JsonValue::String(i)).collect()) + } + } + } +} + +impl From for StoreValue { + fn from(value: JsonValue) -> Self { + match value { + JsonValue::Array(json_values) => { + StoreValue::List(json_values.iter().map(|f| f.to_string()).collect()) + } + JsonValue::String(string) => StoreValue::Value(string), + _ => StoreValue::Value(value.to_string()), + } + } +} + +impl From<&JsonValue> for StoreValue { + fn from(value: &JsonValue) -> Self { + match value { + JsonValue::Array(json_values) => { + StoreValue::List(json_values.iter().map(|f| f.to_string()).collect()) + } + JsonValue::String(string) => StoreValue::Value(string.clone()), + _ => StoreValue::Value(value.to_string()), + } + } +}