haste_health/commands/
config.rs

1use 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}