From 6423baebd92e45c9be85157c443dff42e65d8d14 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 1 Sep 2023 00:00:00 +0000 Subject: refactor: rebuild app skeleton Create skeleton for a complete rebuild of the prototype as a production ready product. Includes design patterns for: - dependency injection - unit testing with dependency mocking - integration testing - error handling - config storage BREAKING-CHANGE: ground-up redesign with incompatible protocol standards --- src/cli_interactor.rs | 34 +++++++++++ src/config.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++++ src/key_handling/mod.rs | 1 + src/key_handling/users.rs | 124 +++++++++++++++++++++++++++++++++++++ src/login.rs | 16 +++++ src/main.rs | 35 +++++++++++ src/sub_commands/login.rs | 11 ++++ src/sub_commands/mod.rs | 1 + 8 files changed, 374 insertions(+) create mode 100644 src/cli_interactor.rs create mode 100644 src/config.rs create mode 100644 src/key_handling/mod.rs create mode 100644 src/key_handling/users.rs create mode 100644 src/login.rs create mode 100644 src/main.rs create mode 100644 src/sub_commands/login.rs create mode 100644 src/sub_commands/mod.rs (limited to 'src') diff --git a/src/cli_interactor.rs b/src/cli_interactor.rs new file mode 100644 index 0000000..2f28aee --- /dev/null +++ b/src/cli_interactor.rs @@ -0,0 +1,34 @@ +use anyhow::{bail, Result}; +use dialoguer::{theme::ColorfulTheme, Input}; +#[cfg(test)] +use mockall::*; + +#[derive(Default)] +pub struct Interactor { + theme: ColorfulTheme, +} + +#[cfg_attr(test, automock)] +pub trait InteractorPrompt { + fn input(&self, parms: PromptInputParms) -> Result; +} +impl InteractorPrompt for Interactor { + fn input(&self, parms: PromptInputParms) -> Result { + let input: String = Input::with_theme(&self.theme) + .with_prompt(parms.prompt) + .interact_text()?; + Ok(input) + } +} + +#[derive(Default)] +pub struct PromptInputParms { + pub prompt: String, +} + +impl PromptInputParms { + pub fn with_prompt>(mut self, prompt: S) -> Self { + self.prompt = prompt.into(); + self + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b26dea0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,152 @@ +use std::{fs::File, io::BufReader}; + +use anyhow::{anyhow, Context, Result}; +use directories::ProjectDirs; +#[cfg(test)] +use mockall::*; +use serde::{self, Deserialize, Serialize}; + +#[derive(Default)] +#[allow(clippy::module_name_repetitions)] +pub struct ConfigManager; + +#[cfg_attr(test, automock)] +#[allow(clippy::module_name_repetitions)] +pub trait ConfigManagement { + fn load(&self) -> Result; + fn save(&self, cfg: &MyConfig) -> Result<()>; +} + +pub fn get_dirs() -> Result { + ProjectDirs::from("", "CodeCollaboration", "ngit").ok_or(anyhow!( + "should find operating system home directories with rust-directories crate" + )) +} + +impl ConfigManagement for ConfigManager { + fn load(&self) -> Result { + let config_path = get_dirs()?.config_dir().join("config.json"); + if config_path.exists() { + let file = + File::open(config_path).context("should open application configuration file")?; + let reader = BufReader::new(file); + let config: MyConfig = serde_json::from_reader(reader) + .context("should read config from config file with serde_json")?; + Ok(config) + } else { + Ok(MyConfig::default()) + } + } + fn save(&self, cfg: &MyConfig) -> Result<()> { + let dirs = get_dirs()?; + let config_path = dirs.config_dir().join("config.json"); + let file = if config_path.exists() { + std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(config_path) + .context( + "should open application configuration file with write and truncate options", + )? + } else { + std::fs::create_dir_all(dirs.config_dir()) + .context("should create application config directories")?; + std::fs::File::create(config_path).context("should create application config file")? + }; + serde_json::to_writer_pretty(file, cfg) + .context("should write configuration to config file with serde_json") + } +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq)] +#[allow(clippy::module_name_repetitions)] +pub struct MyConfig { + pub version: u8, + pub users: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserRef { + pub nsec: String, +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use serial_test::serial; + use test_utils::*; + + use super::*; + + mod load { + use super::*; + + #[test] + #[serial] + fn when_config_file_doesnt_exist_defaults_are_returned() -> Result<()> { + with_fresh_config(|| { + assert_eq!(ConfigManager.load()?, MyConfig::default()); + + Ok(()) + }) + } + + #[test] + #[serial] + fn when_config_file_exists_it_is_returned() -> Result<()> { + with_fresh_config(|| { + let c = ConfigManager; + let new_config = MyConfig { + version: 255, + ..MyConfig::default() + }; + c.save(&new_config)?; + assert_eq!(c.load()?, new_config); + + Ok(()) + }) + } + } + + mod save { + use super::*; + + #[test] + #[serial] + fn when_config_file_doesnt_config_is_saved() -> Result<()> { + with_fresh_config(|| { + let c = ConfigManager; + let new_config = MyConfig { + version: 255, + ..MyConfig::default() + }; + c.save(&new_config)?; + assert_eq!(c.load()?, new_config); + + Ok(()) + }) + } + + #[test] + #[serial] + fn when_config_file_exists_new_config_is_saved() -> Result<()> { + with_fresh_config(|| { + let c = ConfigManager; + let config = MyConfig { + version: 255, + ..MyConfig::default() + }; + c.save(&config)?; + let new_config = MyConfig { + version: 254, + ..MyConfig::default() + }; + c.save(&new_config)?; + assert_eq!(c.load()?, new_config); + + Ok(()) + }) + } + } +} diff --git a/src/key_handling/mod.rs b/src/key_handling/mod.rs new file mode 100644 index 0000000..913bd46 --- /dev/null +++ b/src/key_handling/mod.rs @@ -0,0 +1 @@ +pub mod users; diff --git a/src/key_handling/users.rs b/src/key_handling/users.rs new file mode 100644 index 0000000..bd1748a --- /dev/null +++ b/src/key_handling/users.rs @@ -0,0 +1,124 @@ +use anyhow::{Context, Result}; + +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, + config::{ConfigManagement, ConfigManager, MyConfig, UserRef}, +}; + +#[derive(Default)] +pub struct UserManager { + config_manager: ConfigManager, + interactor: Interactor, +} + +pub trait UserManagement { + fn add(&self, nsec: &Option) -> Result<()>; +} + +#[cfg(test)] +use duplicate::duplicate_item; +#[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))] +impl UserManagement for UserManager { + fn add(&self, nsec: &Option) -> Result<()> { + let nsec = match nsec.clone() { + Some(nsec) => nsec, + None => self + .interactor + .input( + PromptInputParms::default().with_prompt("login with nsec (or hex private key)"), + ) + .context("failed to get nsec input from interactor.input")?, + }; + + self.config_manager + .save(&MyConfig { + users: vec![UserRef { + nsec: nsec.to_string(), + }], + ..MyConfig::default() + }) + .context("failed to save application configuration with new user details in")?; + + println!("logged in as {nsec}"); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use test_utils::*; + + use super::*; + use crate::{cli_interactor::MockInteractorPrompt, config::MockConfigManagement}; + + #[derive(Default)] + pub struct MockUserManager { + pub config_manager: MockConfigManagement, + pub interactor: MockInteractorPrompt, + } + + mod add { + use super::*; + + impl MockUserManager { + fn add_return_expected_responses(mut self) -> Self { + self.config_manager + .expect_load() + .returning(|| Ok(MyConfig::default())); + self.config_manager.expect_save().returning(|_| Ok(())); + self.interactor + .expect_input() + .returning(|_| Ok(TEST_KEY_1_NSEC.into())); + self + } + } + + mod when_nsec_is_passed { + use super::*; + + #[test] + fn user_isnt_prompted() { + let mut m = MockUserManager::default().add_return_expected_responses(); + m.interactor = MockInteractorPrompt::default(); + m.interactor.expect_input().never(); + + let _ = m.add(&Some(TEST_KEY_1_NSEC.into())); + } + } + + mod when_no_nsec_is_passed { + use super::*; + + #[test] + fn prompt_for_nsec() { + let mut m = MockUserManager::default().add_return_expected_responses(); + + m.interactor = MockInteractorPrompt::new(); + m.interactor + .expect_input() + .once() + .withf(|p| p.prompt.eq("login with nsec (or hex private key)")) + .returning(|_| Ok(TEST_KEY_1_NSEC.into())); + + let _ = m.add(&None); + } + + #[test] + fn stored_in_config() { + let mut m = MockUserManager::default().add_return_expected_responses(); + + m.config_manager = MockConfigManagement::new(); + m.config_manager + .expect_load() + .returning(|| Ok(MyConfig::default())); + m.config_manager + .expect_save() + .withf(|cfg| cfg.users.len().eq(&1) && cfg.users[0].nsec.eq(TEST_KEY_1_NSEC)) + .returning(|_| Ok(())); + + let _ = m.add(&None); + } + } + } +} diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..da19a75 --- /dev/null +++ b/src/login.rs @@ -0,0 +1,16 @@ +use anyhow::{Context, Result}; + +use crate::{ + config::{ConfigManagement, ConfigManager}, + key_handling::users::{UserManagement, UserManager}, +}; + +pub fn launch(nsec: &Option) -> Result<()> { + let cfg = ConfigManager + .load() + .context("failed to load application config")?; + if !cfg.users.is_empty() { + println!("logged in as {}", cfg.users[0].nsec); + } + UserManager::default().add(nsec) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d16f1a3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,35 @@ +#![cfg_attr(not(test), warn(clippy::pedantic))] +#![cfg_attr(not(test), warn(clippy::expect_used))] + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +mod cli_interactor; +mod config; +mod key_handling; +mod login; +mod sub_commands; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Cli { + #[command(subcommand)] + command: Commands, + /// nsec or hex private key + #[arg(short, long)] + nsec: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// save encrypted nsec for future use + Login(sub_commands::login::SubCommandArgs), +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match &cli.command { + Commands::Login(args) => sub_commands::login::launch(&cli, args), + } +} diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs new file mode 100644 index 0000000..d61f578 --- /dev/null +++ b/src/sub_commands/login.rs @@ -0,0 +1,11 @@ +use anyhow::Result; +use clap; + +use crate::{login, Cli}; + +#[derive(clap::Args)] +pub struct SubCommandArgs; + +pub fn launch(args: &Cli, _command_args: &SubCommandArgs) -> Result<()> { + login::launch(&args.nsec) +} diff --git a/src/sub_commands/mod.rs b/src/sub_commands/mod.rs new file mode 100644 index 0000000..320cbbb --- /dev/null +++ b/src/sub_commands/mod.rs @@ -0,0 +1 @@ +pub mod login; -- cgit v1.2.3