Skip to main content

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        #[arg(short, long)]
58        name: Option<String>,
59        #[arg(short, long)]
60        r4_url: Option<String>,
61        #[arg(short, long)]
62        discovery_uri: Option<String>,
63        #[arg(short, long)]
64        id: Option<String>,
65        #[arg(short, long)]
66        secret: Option<String>,
67    },
68    DeleteProfile {
69        #[arg(short, long)]
70        name: Option<String>,
71        #[arg(short, long)]
72        confirm: Option<bool>,
73    },
74    SetActiveProfile {
75        #[arg(short, long)]
76        name: Option<String>,
77    },
78}
79
80fn read_existing_config(location: &PathBuf) -> Result<CLIConfiguration, OperationOutcomeError> {
81    let config_str = std::fs::read_to_string(location).map_err(|_| {
82        OperationOutcomeError::error(
83            IssueType::Exception(None),
84            format!(
85                "Failed to read config file at location '{}'",
86                location.to_string_lossy()
87            ),
88        )
89    })?;
90
91    let config = toml::from_str::<CLIConfiguration>(&config_str).map_err(|_| {
92        OperationOutcomeError::error(
93            IssueType::Exception(None),
94            format!(
95                "Failed to parse config file at location '{}'",
96                location.to_string_lossy()
97            ),
98        )
99    })?;
100
101    Ok(config)
102}
103
104pub fn load_config(location: &PathBuf) -> CLIConfiguration {
105    let config: Result<CLIConfiguration, OperationOutcomeError> = read_existing_config(location);
106
107    if let Ok(config) = config {
108        config
109    } else {
110        let config = CLIConfiguration::default();
111
112        std::fs::write(location, toml::to_string(&config).unwrap())
113            .map_err(|_| {
114                OperationOutcomeError::error(
115                    IssueType::Exception(None),
116                    format!(
117                        "Failed to write default config file at location '{}'",
118                        location.to_string_lossy()
119                    ),
120                )
121            })
122            .expect("Failed to write default config file");
123
124        config
125    }
126}
127
128pub async fn config(
129    state: &Arc<Mutex<CLIState>>,
130    command: &ConfigCommands,
131) -> Result<(), OperationOutcomeError> {
132    match command {
133        ConfigCommands::ShowProfile => {
134            let state = state.lock().await;
135            if let Some(active_profile_id) = state.config.active_profile.as_ref()
136                && let Some(active_profile) = state
137                    .config
138                    .profiles
139                    .iter()
140                    .find(|p| &p.name == active_profile_id)
141            {
142                println!("{:#?}", active_profile);
143            } else {
144                println!("No active profile set.");
145            }
146
147            Ok(())
148        }
149        ConfigCommands::CreateProfile {
150            name,
151            r4_url,
152            discovery_uri,
153            id,
154            secret,
155        } => {
156            let name: String = if let Some(name) = name {
157                name.clone()
158            } else {
159                Input::with_theme(&ColorfulTheme::default())
160                    .with_prompt("Profile Name")
161                    .interact_text()
162                    .unwrap()
163            };
164
165            let r4_url: String = if let Some(r4_url) = r4_url {
166                r4_url.clone()
167            } else {
168                Input::with_theme(&ColorfulTheme::default())
169                    .with_prompt("FHIR R4 Server URL")
170                    .interact_text()
171                    .unwrap()
172            };
173
174            let oidc_discovery_uri: String = if let Some(discovery_uri) = discovery_uri {
175                discovery_uri.clone()
176            } else {
177                Input::with_theme(&ColorfulTheme::default())
178                    .with_prompt("OIDC Discovery URI")
179                    .interact_text()
180                    .unwrap()
181            };
182
183            let client_id: String = if let Some(id) = id {
184                id.clone()
185            } else {
186                Input::with_theme(&ColorfulTheme::default())
187                    .with_prompt("OIDC Client ID")
188                    .interact_text()
189                    .unwrap()
190            };
191
192            let client_secret: String = if let Some(secret) = secret {
193                secret.clone()
194            } else {
195                Password::with_theme(&ColorfulTheme::default())
196                    .with_prompt("OIDC Client Secret")
197                    .interact()
198                    .unwrap()
199            };
200
201            let mut state = state.lock().await;
202            if state
203                .config
204                .profiles
205                .iter()
206                .any(|profile| profile.name == *name)
207            {
208                return Err(OperationOutcomeError::error(
209                    IssueType::Exception(None),
210                    format!("Profile with name '{}' already exists", name),
211                ));
212            }
213
214            let profile = Profile {
215                name: name.clone(),
216                r4_url: r4_url.clone(),
217                oidc_discovery_uri: oidc_discovery_uri.clone(),
218                auth: ProfileAuth::ClientCredentails {
219                    client_id: client_id.clone(),
220                    client_secret: client_secret.clone(),
221                },
222            };
223
224            state.config.profiles.push(profile);
225            state.config.active_profile = Some(name.clone());
226
227            std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
228                |_| {
229                    OperationOutcomeError::error(
230                        IssueType::Exception(None),
231                        format!(
232                            "Failed to write config file at location '{}'",
233                            CONFIG_LOCATION.to_string_lossy()
234                        ),
235                    )
236                },
237            )?;
238
239            Ok(())
240        }
241        ConfigCommands::DeleteProfile { name, confirm } => {
242            let name: String = if let Some(name) = name {
243                name.clone()
244            } else {
245                Input::with_theme(&ColorfulTheme::default())
246                    .with_prompt("Enter the profile name you wish to delete")
247                    .interact_text()
248                    .unwrap()
249            };
250
251            let confirmed = if let Some(confirm) = confirm {
252                confirm.clone()
253            } else {
254                Confirm::with_theme(&ColorfulTheme::default())
255                    .with_prompt(format!(
256                        "Are you sure you want to delete the profile '{}'? ",
257                        name
258                    ))
259                    .interact()
260                    .unwrap_or(false)
261            };
262
263            if !confirmed {
264                println!("Profile deletion cancelled.");
265                return Ok(());
266            }
267
268            let mut state = state.lock().await;
269            state
270                .config
271                .profiles
272                .retain(|profile| profile.name != *name);
273
274            std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
275                |_| {
276                    OperationOutcomeError::error(
277                        IssueType::Exception(None),
278                        format!(
279                            "Failed to write config file at location '{}'",
280                            CONFIG_LOCATION.to_string_lossy()
281                        ),
282                    )
283                },
284            )?;
285
286            Ok(())
287        }
288        ConfigCommands::SetActiveProfile { name } => {
289            let mut state = state.lock().await;
290            let user_profile_names = state
291                .config
292                .profiles
293                .iter()
294                .map(|p| p.name.as_str())
295                .collect::<Vec<_>>();
296
297            if user_profile_names.is_empty() {
298                return Err(OperationOutcomeError::error(
299                    IssueType::Exception(None),
300                    "No profiles available to set as active.".to_string(),
301                ));
302            }
303
304            let active_profile_index = state
305                .config
306                .active_profile
307                .as_ref()
308                .and_then(|active_name| {
309                    user_profile_names
310                        .iter()
311                        .position(|&name| name == active_name)
312                })
313                .unwrap_or(0);
314
315            let name: String = if let Some(name) = name {
316                name.clone()
317            } else {
318                let selection = Select::new()
319                    .with_prompt("Choose a profile to set as active.")
320                    .items(&user_profile_names)
321                    .default(active_profile_index)
322                    .interact()
323                    .unwrap();
324                user_profile_names[selection].to_string()
325            };
326
327            if !state
328                .config
329                .profiles
330                .iter()
331                .any(|profile| profile.name == name)
332            {
333                return Err(OperationOutcomeError::error(
334                    IssueType::Exception(None),
335                    format!("Profile with name '{}' does not exist", name),
336                ));
337            }
338
339            state.config.active_profile = Some(name.to_string());
340
341            std::fs::write(&*CONFIG_LOCATION, toml::to_string(&state.config).unwrap()).map_err(
342                |_| {
343                    OperationOutcomeError::error(
344                        IssueType::Exception(None),
345                        format!(
346                            "Failed to write config file at location '{}'",
347                            CONFIG_LOCATION.to_string_lossy()
348                        ),
349                    )
350                },
351            )?;
352            Ok(())
353        }
354    }
355}