upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli_interactor.rs34
-rw-r--r--src/config.rs152
-rw-r--r--src/key_handling/mod.rs1
-rw-r--r--src/key_handling/users.rs124
-rw-r--r--src/login.rs16
-rw-r--r--src/main.rs35
-rw-r--r--src/sub_commands/login.rs11
-rw-r--r--src/sub_commands/mod.rs1
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 @@
1use anyhow::{bail, Result};
2use dialoguer::{theme::ColorfulTheme, Input};
3#[cfg(test)]
4use mockall::*;
5
6#[derive(Default)]
7pub struct Interactor {
8 theme: ColorfulTheme,
9}
10
11#[cfg_attr(test, automock)]
12pub trait InteractorPrompt {
13 fn input(&self, parms: PromptInputParms) -> Result<String>;
14}
15impl 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)]
25pub struct PromptInputParms {
26 pub prompt: String,
27}
28
29impl 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 @@
1use std::{fs::File, io::BufReader};
2
3use anyhow::{anyhow, Context, Result};
4use directories::ProjectDirs;
5#[cfg(test)]
6use mockall::*;
7use serde::{self, Deserialize, Serialize};
8
9#[derive(Default)]
10#[allow(clippy::module_name_repetitions)]
11pub struct ConfigManager;
12
13#[cfg_attr(test, automock)]
14#[allow(clippy::module_name_repetitions)]
15pub trait ConfigManagement {
16 fn load(&self) -> Result<MyConfig>;
17 fn save(&self, cfg: &MyConfig) -> Result<()>;
18}
19
20pub 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
26impl 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)]
64pub struct MyConfig {
65 pub version: u8,
66 pub users: Vec<UserRef>,
67}
68
69#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
70pub struct UserRef {
71 pub nsec: String,
72}
73
74#[cfg(test)]
75mod 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 @@
1use anyhow::{Context, Result};
2
3use crate::{
4 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
5 config::{ConfigManagement, ConfigManager, MyConfig, UserRef},
6};
7
8#[derive(Default)]
9pub struct UserManager {
10 config_manager: ConfigManager,
11 interactor: Interactor,
12}
13
14pub trait UserManagement {
15 fn add(&self, nsec: &Option<String>) -> Result<()>;
16}
17
18#[cfg(test)]
19use duplicate::duplicate_item;
20#[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))]
21impl 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)]
49mod 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 @@
1use anyhow::{Context, Result};
2
3use crate::{
4 config::{ConfigManagement, ConfigManager},
5 key_handling::users::{UserManagement, UserManager},
6};
7
8pub 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
4use anyhow::Result;
5use clap::{Parser, Subcommand};
6
7mod cli_interactor;
8mod config;
9mod key_handling;
10mod login;
11mod sub_commands;
12
13#[derive(Parser)]
14#[command(author, version, about, long_about = None)]
15#[command(propagate_version = true)]
16pub 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)]
25enum Commands {
26 /// save encrypted nsec for future use
27 Login(sub_commands::login::SubCommandArgs),
28}
29
30fn 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 @@
1use anyhow::Result;
2use clap;
3
4use crate::{login, Cli};
5
6#[derive(clap::Args)]
7pub struct SubCommandArgs;
8
9pub 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;