diff --git a/Cargo.toml b/Cargo.toml index ff551da..c8a050d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,16 @@ edition = "2021" license-file = "LICENSE" homepage = "https://github.com/voidcoefficient/cfs" repository = "https://github.com/voidcoefficient/cfs" -keywords = ["cli", "shell-tool", "shell"] +keywords = ["cli", "shell", "shell-tool"] categories = ["command-line-utilities"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -json = "0.12.4" +anyhow = "1.0.102" +dirs = "6.0.0" +rusqlite = { version = "0.39.0", features = ["bundled"] } seahorse = "2.1.0" +serde = { version = "1.0.228", features = ["derive"] } +serde-kdl2 = "0.1.0" +shellexpand = "3.1.2" diff --git a/src/actions.rs b/src/actions.rs index 93a72a0..f0c452e 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,109 +1,116 @@ -use std::fs::File; -use std::io::Write; -use std::path::Path; -use std::process::exit; +use seahorse::{ActionError, ActionResult, Context}; -use json::JsonValue; -use seahorse::Context; +use crate::config::Config; +use crate::error::{invalid, to_action_error}; +use crate::storage::{self, Store, StoreValue}; -use crate::json_object::{get_json_object_or_create, set_json_object}; -use crate::{config::get_config_path, error::invalid}; +pub fn init_action(_c: &Context) -> ActionResult { + let config = Config::load().map_err(to_action_error)?; -pub fn init_action(c: &Context) { - let config_path = get_config_path(); - let path = Path::new(&config_path); + storage::load_storage(&config); - if path.exists() { - println!("config file already exists"); - } else { - clear_action(c); - } + 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 config = Config::load().map_err(to_action_error)?; + + let store = storage::load_storage(&config); - 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 config = Config::load().map_err(to_action_error)?; + + let mut store = storage::load_storage(&config); + + 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 config = Config::load().map_err(to_action_error)?; + + let store = storage::load_storage(&config); + + 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 config = Config::load().map_err(to_action_error)?; - if conf.has_key(key) { - conf.remove(key); - } + let mut store = storage::load_storage(&config); - conf.insert(key, value).unwrap(); + let value = StoreValue::Value(value_str.to_owned()); + store.set(key, value.clone()).map_err(to_action_error)?; - match set_json_object(conf) { - Ok(_) => println!("updated config file"), - Err(err) => eprintln!("{}", err), - } + println!("'{}' -> '{}'", key, value); + + 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; - } + let config = Config::load().map_err(to_action_error)?; - conf.remove(&key); + let mut store = storage::load_storage(&config); - 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..261b31c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,68 @@ -use std::{env::home_dir, path::PathBuf}; +use anyhow::Context; +use dirs::{config_dir, data_local_dir}; +use std::{fs, path::PathBuf}; + +use serde::Deserialize; 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 config_dir() + .expect("couldn't find config directory") + .join("cfs/"); +} + +pub fn get_default_store_path() -> String { + return data_local_dir() + .expect("couldn't find data directory") + .join("cfs.db") + .to_str() + .unwrap() + .to_owned(); +} + +#[derive(Clone, Copy, Debug, Deserialize)] +pub enum StoreType { + Sqlite, +} +impl Default for StoreType { + fn default() -> Self { + Self::Sqlite + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + #[serde(default)] + pub store: StoreType, + #[serde(default = "get_default_store_path")] + pub store_path: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + store: StoreType::Sqlite, + store_path: get_default_store_path(), + } + } +} + +impl Config { + pub fn load() -> anyhow::Result { + let config_file_path = get_config_path().join("config.kdl"); + + if !config_file_path.exists() { + return Ok(Self::default()); + } + + let s = fs::read_to_string(config_file_path).context("couldn't read config file")?; + let mut config = serde_kdl2::from_str::(&s).context("couldn't parse config file")?; + + let path = shellexpand::full(&config.store_path) + .context("failed to parse store path")? + .to_string(); + + config.store_path = path; - return path; + Ok(config) + } } 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/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..e730182 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,24 @@ +use anyhow::Result; + +pub mod sqlite; +mod value; + +pub use value::StoreValue; + +use crate::config::{Config, StoreType}; + +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; +} + +pub fn load_storage(config: &Config) -> impl Store { + match config.store { + StoreType::Sqlite => return sqlite::SQLiteStore::new(&config.store_path), + } +} diff --git a/src/storage/sqlite/mod.rs b/src/storage/sqlite/mod.rs new file mode 100644 index 0000000..18e3425 --- /dev/null +++ b/src/storage/sqlite/mod.rs @@ -0,0 +1,86 @@ +use anyhow::{anyhow, Result}; + +use rusqlite::OptionalExtension as _; + +use crate::storage::{Store, StoreValue}; + +#[derive(Debug)] +pub struct SQLiteStore { + connection: rusqlite::Connection, +} + +impl SQLiteStore { + pub fn new(path: &str) -> 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..807e7e7 --- /dev/null +++ b/src/storage/value.rs @@ -0,0 +1,23 @@ +use std::fmt::Display; + +#[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) + } + } + } +}