diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli_interactor.rs | 34 | ||||
| -rw-r--r-- | src/config.rs | 152 | ||||
| -rw-r--r-- | src/key_handling/mod.rs | 1 | ||||
| -rw-r--r-- | src/key_handling/users.rs | 124 | ||||
| -rw-r--r-- | src/login.rs | 16 | ||||
| -rw-r--r-- | src/main.rs | 35 | ||||
| -rw-r--r-- | src/sub_commands/login.rs | 11 | ||||
| -rw-r--r-- | src/sub_commands/mod.rs | 1 |
8 files changed, 374 insertions, 0 deletions
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 @@ | |||
| 1 | use anyhow::{bail, Result}; | ||
| 2 | use dialoguer::{theme::ColorfulTheme, Input}; | ||
| 3 | #[cfg(test)] | ||
| 4 | use mockall::*; | ||
| 5 | |||
| 6 | #[derive(Default)] | ||
| 7 | pub struct Interactor { | ||
| 8 | theme: ColorfulTheme, | ||
| 9 | } | ||
| 10 | |||
| 11 | #[cfg_attr(test, automock)] | ||
| 12 | pub trait InteractorPrompt { | ||
| 13 | fn input(&self, parms: PromptInputParms) -> Result<String>; | ||
| 14 | } | ||
| 15 | impl InteractorPrompt for Interactor { | ||
| 16 | fn input(&self, parms: PromptInputParms) -> Result<String> { | ||
| 17 | let input: String = Input::with_theme(&self.theme) | ||
| 18 | .with_prompt(parms.prompt) | ||
| 19 | .interact_text()?; | ||
| 20 | Ok(input) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | #[derive(Default)] | ||
| 25 | pub struct PromptInputParms { | ||
| 26 | pub prompt: String, | ||
| 27 | } | ||
| 28 | |||
| 29 | impl PromptInputParms { | ||
| 30 | pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self { | ||
| 31 | self.prompt = prompt.into(); | ||
| 32 | self | ||
| 33 | } | ||
| 34 | } | ||
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 @@ | |||
| 1 | use std::{fs::File, io::BufReader}; | ||
| 2 | |||
| 3 | use anyhow::{anyhow, Context, Result}; | ||
| 4 | use directories::ProjectDirs; | ||
| 5 | #[cfg(test)] | ||
| 6 | use mockall::*; | ||
| 7 | use serde::{self, Deserialize, Serialize}; | ||
| 8 | |||
| 9 | #[derive(Default)] | ||
| 10 | #[allow(clippy::module_name_repetitions)] | ||
| 11 | pub struct ConfigManager; | ||
| 12 | |||
| 13 | #[cfg_attr(test, automock)] | ||
| 14 | #[allow(clippy::module_name_repetitions)] | ||
| 15 | pub trait ConfigManagement { | ||
| 16 | fn load(&self) -> Result<MyConfig>; | ||
| 17 | fn save(&self, cfg: &MyConfig) -> Result<()>; | ||
| 18 | } | ||
| 19 | |||
| 20 | pub fn get_dirs() -> Result<ProjectDirs> { | ||
| 21 | ProjectDirs::from("", "CodeCollaboration", "ngit").ok_or(anyhow!( | ||
| 22 | "should find operating system home directories with rust-directories crate" | ||
| 23 | )) | ||
| 24 | } | ||
| 25 | |||
| 26 | impl ConfigManagement for ConfigManager { | ||
| 27 | fn load(&self) -> Result<MyConfig> { | ||
| 28 | let config_path = get_dirs()?.config_dir().join("config.json"); | ||
| 29 | if config_path.exists() { | ||
| 30 | let file = | ||
| 31 | File::open(config_path).context("should open application configuration file")?; | ||
| 32 | let reader = BufReader::new(file); | ||
| 33 | let config: MyConfig = serde_json::from_reader(reader) | ||
| 34 | .context("should read config from config file with serde_json")?; | ||
| 35 | Ok(config) | ||
| 36 | } else { | ||
| 37 | Ok(MyConfig::default()) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | fn save(&self, cfg: &MyConfig) -> Result<()> { | ||
| 41 | let dirs = get_dirs()?; | ||
| 42 | let config_path = dirs.config_dir().join("config.json"); | ||
| 43 | let file = if config_path.exists() { | ||
| 44 | std::fs::OpenOptions::new() | ||
| 45 | .create(true) | ||
| 46 | .write(true) | ||
| 47 | .truncate(true) | ||
| 48 | .open(config_path) | ||
| 49 | .context( | ||
| 50 | "should open application configuration file with write and truncate options", | ||
| 51 | )? | ||
| 52 | } else { | ||
| 53 | std::fs::create_dir_all(dirs.config_dir()) | ||
| 54 | .context("should create application config directories")?; | ||
| 55 | std::fs::File::create(config_path).context("should create application config file")? | ||
| 56 | }; | ||
| 57 | serde_json::to_writer_pretty(file, cfg) | ||
| 58 | .context("should write configuration to config file with serde_json") | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq)] | ||
| 63 | #[allow(clippy::module_name_repetitions)] | ||
| 64 | pub struct MyConfig { | ||
| 65 | pub version: u8, | ||
| 66 | pub users: Vec<UserRef>, | ||
| 67 | } | ||
| 68 | |||
| 69 | #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] | ||
| 70 | pub struct UserRef { | ||
| 71 | pub nsec: String, | ||
| 72 | } | ||
| 73 | |||
| 74 | #[cfg(test)] | ||
| 75 | mod tests { | ||
| 76 | use anyhow::Result; | ||
| 77 | use serial_test::serial; | ||
| 78 | use test_utils::*; | ||
| 79 | |||
| 80 | use super::*; | ||
| 81 | |||
| 82 | mod load { | ||
| 83 | use super::*; | ||
| 84 | |||
| 85 | #[test] | ||
| 86 | #[serial] | ||
| 87 | fn when_config_file_doesnt_exist_defaults_are_returned() -> Result<()> { | ||
| 88 | with_fresh_config(|| { | ||
| 89 | assert_eq!(ConfigManager.load()?, MyConfig::default()); | ||
| 90 | |||
| 91 | Ok(()) | ||
| 92 | }) | ||
| 93 | } | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | #[serial] | ||
| 97 | fn when_config_file_exists_it_is_returned() -> Result<()> { | ||
| 98 | with_fresh_config(|| { | ||
| 99 | let c = ConfigManager; | ||
| 100 | let new_config = MyConfig { | ||
| 101 | version: 255, | ||
| 102 | ..MyConfig::default() | ||
| 103 | }; | ||
| 104 | c.save(&new_config)?; | ||
| 105 | assert_eq!(c.load()?, new_config); | ||
| 106 | |||
| 107 | Ok(()) | ||
| 108 | }) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | mod save { | ||
| 113 | use super::*; | ||
| 114 | |||
| 115 | #[test] | ||
| 116 | #[serial] | ||
| 117 | fn when_config_file_doesnt_config_is_saved() -> Result<()> { | ||
| 118 | with_fresh_config(|| { | ||
| 119 | let c = ConfigManager; | ||
| 120 | let new_config = MyConfig { | ||
| 121 | version: 255, | ||
| 122 | ..MyConfig::default() | ||
| 123 | }; | ||
| 124 | c.save(&new_config)?; | ||
| 125 | assert_eq!(c.load()?, new_config); | ||
| 126 | |||
| 127 | Ok(()) | ||
| 128 | }) | ||
| 129 | } | ||
| 130 | |||
| 131 | #[test] | ||
| 132 | #[serial] | ||
| 133 | fn when_config_file_exists_new_config_is_saved() -> Result<()> { | ||
| 134 | with_fresh_config(|| { | ||
| 135 | let c = ConfigManager; | ||
| 136 | let config = MyConfig { | ||
| 137 | version: 255, | ||
| 138 | ..MyConfig::default() | ||
| 139 | }; | ||
| 140 | c.save(&config)?; | ||
| 141 | let new_config = MyConfig { | ||
| 142 | version: 254, | ||
| 143 | ..MyConfig::default() | ||
| 144 | }; | ||
| 145 | c.save(&new_config)?; | ||
| 146 | assert_eq!(c.load()?, new_config); | ||
| 147 | |||
| 148 | Ok(()) | ||
| 149 | }) | ||
| 150 | } | ||
| 151 | } | ||
| 152 | } | ||
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 @@ | |||
| 1 | use anyhow::{Context, Result}; | ||
| 2 | |||
| 3 | use crate::{ | ||
| 4 | cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, | ||
| 5 | config::{ConfigManagement, ConfigManager, MyConfig, UserRef}, | ||
| 6 | }; | ||
| 7 | |||
| 8 | #[derive(Default)] | ||
| 9 | pub struct UserManager { | ||
| 10 | config_manager: ConfigManager, | ||
| 11 | interactor: Interactor, | ||
| 12 | } | ||
| 13 | |||
| 14 | pub trait UserManagement { | ||
| 15 | fn add(&self, nsec: &Option<String>) -> Result<()>; | ||
| 16 | } | ||
| 17 | |||
| 18 | #[cfg(test)] | ||
| 19 | use duplicate::duplicate_item; | ||
| 20 | #[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))] | ||
| 21 | impl UserManagement for UserManager { | ||
| 22 | fn add(&self, nsec: &Option<String>) -> Result<()> { | ||
| 23 | let nsec = match nsec.clone() { | ||
| 24 | Some(nsec) => nsec, | ||
| 25 | None => self | ||
| 26 | .interactor | ||
| 27 | .input( | ||
| 28 | PromptInputParms::default().with_prompt("login with nsec (or hex private key)"), | ||
| 29 | ) | ||
| 30 | .context("failed to get nsec input from interactor.input")?, | ||
| 31 | }; | ||
| 32 | |||
| 33 | self.config_manager | ||
| 34 | .save(&MyConfig { | ||
| 35 | users: vec![UserRef { | ||
| 36 | nsec: nsec.to_string(), | ||
| 37 | }], | ||
| 38 | ..MyConfig::default() | ||
| 39 | }) | ||
| 40 | .context("failed to save application configuration with new user details in")?; | ||
| 41 | |||
| 42 | println!("logged in as {nsec}"); | ||
| 43 | |||
| 44 | Ok(()) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | #[cfg(test)] | ||
| 49 | mod tests { | ||
| 50 | use test_utils::*; | ||
| 51 | |||
| 52 | use super::*; | ||
| 53 | use crate::{cli_interactor::MockInteractorPrompt, config::MockConfigManagement}; | ||
| 54 | |||
| 55 | #[derive(Default)] | ||
| 56 | pub struct MockUserManager { | ||
| 57 | pub config_manager: MockConfigManagement, | ||
| 58 | pub interactor: MockInteractorPrompt, | ||
| 59 | } | ||
| 60 | |||
| 61 | mod add { | ||
| 62 | use super::*; | ||
| 63 | |||
| 64 | impl MockUserManager { | ||
| 65 | fn add_return_expected_responses(mut self) -> Self { | ||
| 66 | self.config_manager | ||
| 67 | .expect_load() | ||
| 68 | .returning(|| Ok(MyConfig::default())); | ||
| 69 | self.config_manager.expect_save().returning(|_| Ok(())); | ||
| 70 | self.interactor | ||
| 71 | .expect_input() | ||
| 72 | .returning(|_| Ok(TEST_KEY_1_NSEC.into())); | ||
| 73 | self | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | mod when_nsec_is_passed { | ||
| 78 | use super::*; | ||
| 79 | |||
| 80 | #[test] | ||
| 81 | fn user_isnt_prompted() { | ||
| 82 | let mut m = MockUserManager::default().add_return_expected_responses(); | ||
| 83 | m.interactor = MockInteractorPrompt::default(); | ||
| 84 | m.interactor.expect_input().never(); | ||
| 85 | |||
| 86 | let _ = m.add(&Some(TEST_KEY_1_NSEC.into())); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | mod when_no_nsec_is_passed { | ||
| 91 | use super::*; | ||
| 92 | |||
| 93 | #[test] | ||
| 94 | fn prompt_for_nsec() { | ||
| 95 | let mut m = MockUserManager::default().add_return_expected_responses(); | ||
| 96 | |||
| 97 | m.interactor = MockInteractorPrompt::new(); | ||
| 98 | m.interactor | ||
| 99 | .expect_input() | ||
| 100 | .once() | ||
| 101 | .withf(|p| p.prompt.eq("login with nsec (or hex private key)")) | ||
| 102 | .returning(|_| Ok(TEST_KEY_1_NSEC.into())); | ||
| 103 | |||
| 104 | let _ = m.add(&None); | ||
| 105 | } | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn stored_in_config() { | ||
| 109 | let mut m = MockUserManager::default().add_return_expected_responses(); | ||
| 110 | |||
| 111 | m.config_manager = MockConfigManagement::new(); | ||
| 112 | m.config_manager | ||
| 113 | .expect_load() | ||
| 114 | .returning(|| Ok(MyConfig::default())); | ||
| 115 | m.config_manager | ||
| 116 | .expect_save() | ||
| 117 | .withf(|cfg| cfg.users.len().eq(&1) && cfg.users[0].nsec.eq(TEST_KEY_1_NSEC)) | ||
| 118 | .returning(|_| Ok(())); | ||
| 119 | |||
| 120 | let _ = m.add(&None); | ||
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
| 124 | } | ||
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 @@ | |||
| 1 | use anyhow::{Context, Result}; | ||
| 2 | |||
| 3 | use crate::{ | ||
| 4 | config::{ConfigManagement, ConfigManager}, | ||
| 5 | key_handling::users::{UserManagement, UserManager}, | ||
| 6 | }; | ||
| 7 | |||
| 8 | pub fn launch(nsec: &Option<String>) -> Result<()> { | ||
| 9 | let cfg = ConfigManager | ||
| 10 | .load() | ||
| 11 | .context("failed to load application config")?; | ||
| 12 | if !cfg.users.is_empty() { | ||
| 13 | println!("logged in as {}", cfg.users[0].nsec); | ||
| 14 | } | ||
| 15 | UserManager::default().add(nsec) | ||
| 16 | } | ||
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 @@ | |||
| 1 | #![cfg_attr(not(test), warn(clippy::pedantic))] | ||
| 2 | #![cfg_attr(not(test), warn(clippy::expect_used))] | ||
| 3 | |||
| 4 | use anyhow::Result; | ||
| 5 | use clap::{Parser, Subcommand}; | ||
| 6 | |||
| 7 | mod cli_interactor; | ||
| 8 | mod config; | ||
| 9 | mod key_handling; | ||
| 10 | mod login; | ||
| 11 | mod sub_commands; | ||
| 12 | |||
| 13 | #[derive(Parser)] | ||
| 14 | #[command(author, version, about, long_about = None)] | ||
| 15 | #[command(propagate_version = true)] | ||
| 16 | pub struct Cli { | ||
| 17 | #[command(subcommand)] | ||
| 18 | command: Commands, | ||
| 19 | /// nsec or hex private key | ||
| 20 | #[arg(short, long)] | ||
| 21 | nsec: Option<String>, | ||
| 22 | } | ||
| 23 | |||
| 24 | #[derive(Subcommand)] | ||
| 25 | enum Commands { | ||
| 26 | /// save encrypted nsec for future use | ||
| 27 | Login(sub_commands::login::SubCommandArgs), | ||
| 28 | } | ||
| 29 | |||
| 30 | fn main() -> Result<()> { | ||
| 31 | let cli = Cli::parse(); | ||
| 32 | match &cli.command { | ||
| 33 | Commands::Login(args) => sub_commands::login::launch(&cli, args), | ||
| 34 | } | ||
| 35 | } | ||
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 @@ | |||
| 1 | use anyhow::Result; | ||
| 2 | use clap; | ||
| 3 | |||
| 4 | use crate::{login, Cli}; | ||
| 5 | |||
| 6 | #[derive(clap::Args)] | ||
| 7 | pub struct SubCommandArgs; | ||
| 8 | |||
| 9 | pub fn launch(args: &Cli, _command_args: &SubCommandArgs) -> Result<()> { | ||
| 10 | login::launch(&args.nsec) | ||
| 11 | } | ||
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; | |||