haste_health/commands/
config.rs1use crate::{CLIState, CONFIG_LOCATION};
2use clap::Subcommand;
3use dialoguer::{Confirm, Select};
4use dialoguer::{Input, Password, theme::ColorfulTheme};
5use haste_fhir_model::r4::generated::terminology::IssueType;
6use haste_fhir_operation_error::OperationOutcomeError;
7use serde::{Deserialize, Serialize};
8use std::{path::PathBuf, sync::Arc};
9use tokio::sync::Mutex;
10
11#[derive(Serialize, Deserialize, Debug)]
12pub struct CLIConfiguration {
13 pub active_profile: Option<String>,
14 pub profiles: Vec<Profile>,
15}
16
17impl CLIConfiguration {
18 pub fn current_profile(&self) -> Option<&Profile> {
19 if let Some(active_profile_id) = self.active_profile.as_ref() {
20 self.profiles.iter().find(|p| &p.name == active_profile_id)
21 } else {
22 None
23 }
24 }
25}
26
27impl Default for CLIConfiguration {
28 fn default() -> Self {
29 CLIConfiguration {
30 active_profile: None,
31 profiles: vec![],
32 }
33 }
34}
35
36#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct Profile {
38 pub name: String,
39 pub r4_url: String,
40 pub oidc_discovery_uri: String,
41 pub auth: ProfileAuth,
42}
43
44#[derive(Serialize, Deserialize, Debug, Clone)]
45pub enum ProfileAuth {
46 ClientCredentails {
47 client_id: String,
48 client_secret: String,
49 },
50 Public {},
51}
52
53#[derive(Subcommand, Debug)]
54pub enum ConfigCommands {
55 ShowProfile,
56 CreateProfile {},
57 DeleteProfile {},
58 SetActiveProfile,
59}
60
61fn read_existing_config(location: &PathBuf) -> Result<CLIConfiguration, OperationOutcomeError> {
62 let config_str = std::fs::read_to_string(location).map_err(|_| {
63 OperationOutcomeError::error(
64 IssueType::Exception(None),
65 format!(
66 "Failed to read config file at location '{}'",
67 location.to_string_lossy()
68 ),
69 )
70 })?;
71
72 let config = toml::from_str::<CLIConfiguration>(&config_str).map_err(|_| {
73 OperationOutcomeError::error(
74 IssueType::Exception(None),
75 format!(
76 "Failed to parse config file at location '{}'",
77 location.to_string_lossy()
78 ),
79 )
80 })?;
81
82 Ok(config)
83}
84
85pub fn load_config(location: &PathBuf) -> CLIConfiguration {
86 let config: Result<CLIConfiguration, OperationOutcomeError> = read_existing_config(location);
87
88 if let Ok(config) = config {
89 config
90 } else {
91 let config = CLIConfiguration::default();
92
93 std::fs::write(location, toml::to_string(&config).unwrap())
94 .map_err(|_| {
95 OperationOutcomeError::error(
96 IssueType::Exception(None),
97 format!(
98 "Failed to write default config file at location '{}'",
99 location.to_string_lossy()
100 ),
101 )
102 })
103 .expect("Failed to write default config file");
104
105 config
106 }
107}
108
109pub async fn config(
110 state: &Arc<Mutex<CLIState>>,
111 command: &ConfigCommands,
112) -> Result<(), OperationOutcomeError> {
113 match command {
114 ConfigCommands::ShowProfile => {
115 let state = state.lock().await;
116 if let Some(active_profile_id) = state.config.active_profile.as_ref()
117 && let Some(active_profile) = state
118 .config
119 .profiles
120 .iter()
121 .find(|p| &p.name == active_profile_id)
122 {
123 println!("{:#?}", active_profile);
124 } else {
125 println!("No active profile set.");
126 }
127
128 Ok(())
129 }
130 ConfigCommands::CreateProfile {} => {
131 let name: String = Input::with_theme(&ColorfulTheme::default())
132 .with_prompt("Profile Name")
133 .interact_text()
134 .unwrap();
135
136 let r4_url: String = Input::with_theme(&ColorfulTheme::default())
137 .with_prompt("FHIR R4 Server URL")
138 .interact_text()
139 .unwrap();
140 let oidc_discovery_uri: String = Input::with_theme(&ColorfulTheme::default())
141 .with_prompt("OIDC Discovery URI")
142 .interact_text()
143 .unwrap();
144 let client_id: String = Input::with_theme(&ColorfulTheme::default())
145 .with_prompt("OIDC Client ID")
146 .interact_text()
147 .unwrap();
148 let client_secret: String = Password::with_theme(&ColorfulTheme::default())
149 .with_prompt("OIDC Client Secret")
150 .interact()
151 .unwrap();
152
153 let mut state = state.lock().await;
154 if state
155 .config
156 .profiles
157 .iter()
158 .any(|profile| profile.name == *name)
159 {
160 return Err(OperationOutcomeError::error(
161 IssueType::Exception(None),
162 format!("Profile with name '{}' already exists", name),
163 ));
164 }
165
166 let profile = Profile {
167 name: name.clone(),
168 r4_url: r4_url.clone(),
169 oidc_discovery_uri: oidc_discovery_uri.clone(),
170 auth: ProfileAuth::ClientCredentails {
171 client_id: client_id.clone(),
172 client_secret: client_secret.clone(),
173 },
174 };
175
176 state.config.profiles.push(profile);
177 state.config.active_profile = Some(name.clone());
178
179 std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
180 |_| {
181 OperationOutcomeError::error(
182 IssueType::Exception(None),
183 format!(
184 "Failed to write config file at location '{}'",
185 CONFIG_LOCATION.to_string_lossy()
186 ),
187 )
188 },
189 )?;
190
191 Ok(())
192 }
193 ConfigCommands::DeleteProfile {} => {
194 let name: String = Input::with_theme(&ColorfulTheme::default())
195 .with_prompt("Enter the profile name you wish to delete")
196 .interact_text()
197 .unwrap();
198
199 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
200 .with_prompt(format!(
201 "Are you sure you want to delete the profile '{}'? ",
202 name
203 ))
204 .interact()
205 .unwrap_or(false);
206
207 if !confirmed {
208 println!("Profile deletion cancelled.");
209 return Ok(());
210 }
211
212 let mut state = state.lock().await;
213 state
214 .config
215 .profiles
216 .retain(|profile| profile.name != *name);
217
218 std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
219 |_| {
220 OperationOutcomeError::error(
221 IssueType::Exception(None),
222 format!(
223 "Failed to write config file at location '{}'",
224 CONFIG_LOCATION.to_string_lossy()
225 ),
226 )
227 },
228 )?;
229
230 Ok(())
231 }
232 ConfigCommands::SetActiveProfile => {
233 let mut state = state.lock().await;
234 let user_profile_names = state
235 .config
236 .profiles
237 .iter()
238 .map(|p| p.name.as_str())
239 .collect::<Vec<_>>();
240
241 if user_profile_names.is_empty() {
242 return Err(OperationOutcomeError::error(
243 IssueType::Exception(None),
244 "No profiles available to set as active.".to_string(),
245 ));
246 }
247
248 let active_profile_index = state
249 .config
250 .active_profile
251 .as_ref()
252 .and_then(|active_name| {
253 user_profile_names
254 .iter()
255 .position(|&name| name == active_name)
256 })
257 .unwrap_or(0);
258
259 let selection = Select::new()
260 .with_prompt("Choose a profile to set as active.")
261 .items(&user_profile_names)
262 .default(active_profile_index)
263 .interact()
264 .unwrap();
265
266 let name = user_profile_names[selection];
267
268 if !state
269 .config
270 .profiles
271 .iter()
272 .any(|profile| profile.name == *name)
273 {
274 return Err(OperationOutcomeError::error(
275 IssueType::Exception(None),
276 format!("Profile with name '{}' does not exist", name),
277 ));
278 }
279
280 state.config.active_profile = Some(name.to_string());
281
282 std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
283 |_| {
284 OperationOutcomeError::error(
285 IssueType::Exception(None),
286 format!(
287 "Failed to write config file at location '{}'",
288 CONFIG_LOCATION.to_string_lossy()
289 ),
290 )
291 },
292 )?;
293 Ok(())
294 }
295 }
296}