Skip to main content

haste_health/commands/
config.rs

1//! `config` command: manage named server connection profiles.
2//!
3//! This module only owns the CLI surface (argument parsing, prompts). The persisted
4//! schema lives in [`crate::cli::config`]; secrets in [`crate::cli::secrets`].
5
6use crate::cli::{
7    config::{CliConfiguration, Profile, ProfileAuth, write_config},
8    secrets,
9    state::{CONFIG_LOCATION, CliState, SECRETS_LOCATION},
10};
11use clap::{Subcommand, ValueEnum};
12use dialoguer::{Confirm, Select};
13use dialoguer::{Input, Password, theme::ColorfulTheme};
14use haste_fhir_model::r4::generated::terminology::IssueType;
15use haste_fhir_operation_error::OperationOutcomeError;
16use std::sync::Arc;
17use tokio::sync::Mutex;
18
19/// How the CLI authenticates for a newly created profile.
20#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
21pub(crate) enum AuthModeChoice {
22    /// A confidential (server-to-server) client authenticated with a client secret.
23    ClientCredentials,
24    /// A public client a human logs into via the browser (authorization_code + PKCE).
25    /// Use `haste-health login` afterwards to obtain tokens.
26    AuthorizationCode,
27    /// Basic authentication with a username and password.
28    BasicAuth,
29}
30
31/// Manage named server connection profiles.
32#[derive(Subcommand, Debug)]
33pub(crate) enum ConfigCommands {
34    /// Print the currently active profile (never includes secrets or tokens).
35    ShowProfile,
36    /// Create a new profile and set it as active. Prompts interactively for any option
37    /// not passed on the command line.
38    CreateProfile {
39        /// Name to identify this profile by.
40        #[arg(short, long)]
41        name: Option<String>,
42        /// Base URL of the FHIR R4 server.
43        #[arg(short, long)]
44        r4_url: Option<String>,
45        /// OIDC discovery (`.well-known/openid-configuration`) URI.
46        #[arg(short, long)]
47        discovery_uri: Option<String>,
48        /// How the CLI should authenticate as this profile.
49        #[arg(long, value_enum)]
50        auth_mode: Option<AuthModeChoice>,
51        /// OIDC client ID.
52        #[arg(short, long)]
53        id: Option<String>,
54        /// Client secret. Required for --auth-mode client-credentials, ignored otherwise.
55        /// Stored in the secrets file, not the profile itself.
56        #[arg(short, long)]
57        secret: Option<String>,
58        /// Loopback redirect URI for --auth-mode authorization-code (must be registered on the server client).
59        #[arg(long)]
60        redirect_uri: Option<String>,
61        /// OAuth scope to request for --auth-mode authorization-code.
62        #[arg(long)]
63        scope: Option<String>,
64    },
65    /// Delete a profile and its stored secrets.
66    DeleteProfile {
67        /// Name of the profile to delete.
68        #[arg(short, long)]
69        name: Option<String>,
70        /// Skip the interactive confirmation prompt.
71        #[arg(short, long)]
72        confirm: Option<bool>,
73    },
74    /// Change which profile is used by default.
75    SetActiveProfile {
76        /// Name of the profile to activate.
77        #[arg(short, long)]
78        name: Option<String>,
79    },
80}
81
82fn persist(config: &CliConfiguration) -> Result<(), OperationOutcomeError> {
83    write_config(&CONFIG_LOCATION, config)
84}
85
86fn select_profile_name(state: &CliState, prompt: &str) -> Result<String, OperationOutcomeError> {
87    let profile_names = state
88        .config
89        .profiles
90        .iter()
91        .map(|profile| profile.name.as_str())
92        .collect::<Vec<_>>();
93
94    if profile_names.is_empty() {
95        return Err(OperationOutcomeError::error(
96            IssueType::exception(),
97            "No profiles available.".to_string(),
98        ));
99    }
100
101    let active_profile_index = state
102        .config
103        .active_profile
104        .as_ref()
105        .and_then(|active_name| profile_names.iter().position(|&name| name == active_name))
106        .unwrap_or(0);
107
108    let selection = Select::with_theme(&ColorfulTheme::default())
109        .with_prompt(prompt)
110        .items(&profile_names)
111        .default(active_profile_index)
112        .interact()
113        .unwrap();
114
115    Ok(profile_names[selection].to_string())
116}
117
118/// Runs the `config` command group.
119pub(crate) async fn run(
120    state: Arc<Mutex<CliState>>,
121    command: &ConfigCommands,
122) -> Result<(), OperationOutcomeError> {
123    match command {
124        ConfigCommands::ShowProfile => {
125            let state = state.lock().await;
126            if let Some(active_profile) = state.config.current_profile() {
127                println!("{:#?}", active_profile);
128            } else {
129                println!("No active profile set.");
130            }
131
132            Ok(())
133        }
134        ConfigCommands::CreateProfile {
135            name,
136            r4_url,
137            discovery_uri,
138            auth_mode,
139            id,
140            secret,
141            redirect_uri,
142            scope,
143        } => {
144            let name: String = if let Some(name) = name {
145                name.clone()
146            } else {
147                Input::with_theme(&ColorfulTheme::default())
148                    .with_prompt("Profile Name")
149                    .interact_text()
150                    .unwrap()
151            };
152
153            let r4_url: String = if let Some(r4_url) = r4_url {
154                r4_url.clone()
155            } else {
156                Input::with_theme(&ColorfulTheme::default())
157                    .with_prompt("FHIR R4 Server URL")
158                    .interact_text()
159                    .unwrap()
160            };
161
162            let oidc_discovery_uri: String = if let Some(discovery_uri) = discovery_uri {
163                discovery_uri.clone()
164            } else {
165                Input::with_theme(&ColorfulTheme::default())
166                    .with_prompt("OIDC Discovery URI")
167                    .interact_text()
168                    .unwrap()
169            };
170
171            let client_id: String = if let Some(id) = id {
172                id.clone()
173            } else {
174                Input::with_theme(&ColorfulTheme::default())
175                    .with_prompt("OIDC Client ID")
176                    .interact_text()
177                    .unwrap()
178            };
179
180            let auth_mode: AuthModeChoice = match auth_mode {
181                Some(mode) => mode.clone(),
182                None => {
183                    let options = ["Authorization Code (browser login)", "Client Credentials"];
184                    let selection = Select::with_theme(&ColorfulTheme::default())
185                        .with_prompt("Auth Mode")
186                        .items(&options)
187                        .default(0)
188                        .interact()
189                        .unwrap();
190
191                    match selection {
192                        1 => AuthModeChoice::ClientCredentials,
193                        _ => AuthModeChoice::AuthorizationCode,
194                    }
195                }
196            };
197
198            let (auth, client_secret) = match auth_mode {
199                AuthModeChoice::BasicAuth => {
200                    let password: String = if let Some(secret) = secret {
201                        secret.clone()
202                    } else {
203                        Password::with_theme(&ColorfulTheme::default())
204                            .with_prompt("Password")
205                            .interact()
206                            .unwrap()
207                    };
208
209                    (
210                        ProfileAuth::Basic {
211                            username: client_id.clone(),
212                        },
213                        Some(password),
214                    )
215                }
216                AuthModeChoice::ClientCredentials => {
217                    let client_secret: String = if let Some(secret) = secret {
218                        secret.clone()
219                    } else {
220                        Password::with_theme(&ColorfulTheme::default())
221                            .with_prompt("OIDC Client Secret")
222                            .interact()
223                            .unwrap()
224                    };
225
226                    (
227                        ProfileAuth::ClientCredentails {
228                            client_id: client_id.clone(),
229                        },
230                        Some(client_secret),
231                    )
232                }
233                AuthModeChoice::AuthorizationCode => {
234                    let redirect_uri: String = if let Some(redirect_uri) = redirect_uri {
235                        redirect_uri.clone()
236                    } else {
237                        Input::with_theme(&ColorfulTheme::default())
238                            .with_prompt("Loopback Redirect URI")
239                            .default("http://127.0.0.1:8976/callback".to_string())
240                            .interact_text()
241                            .unwrap()
242                    };
243
244                    let scope: String = if let Some(scope) = scope {
245                        scope.clone()
246                    } else {
247                        Input::with_theme(&ColorfulTheme::default())
248                            .with_prompt("OAuth Scope")
249                            .default("openid profile fhirUser offline_access user/*.*".to_string())
250                            .interact_text()
251                            .unwrap()
252                    };
253
254                    (
255                        ProfileAuth::AuthorizationCode {
256                            client_id: client_id.clone(),
257                            redirect_uri,
258                            scope,
259                        },
260                        None,
261                    )
262                }
263            };
264
265            let mut state = state.lock().await;
266            if state
267                .config
268                .profiles
269                .iter()
270                .any(|profile| profile.name == *name)
271            {
272                return Err(OperationOutcomeError::error(
273                    IssueType::exception(),
274                    format!("Profile with name '{}' already exists", name),
275                ));
276            }
277
278            let profile = Profile {
279                name: name.clone(),
280                r4_url: r4_url.clone(),
281                oidc_discovery_uri: oidc_discovery_uri.clone(),
282                auth,
283            };
284
285            state.config.profiles.push(profile);
286            state.config.active_profile = Some(name.clone());
287
288            if let Some(client_secret) = client_secret {
289                state.secrets.profile_mut(&name).client_secret = Some(client_secret);
290                secrets::write_secrets(&SECRETS_LOCATION, &state.secrets)?;
291            }
292
293            persist(&state.config)
294        }
295        ConfigCommands::DeleteProfile { name, confirm } => {
296            let name: String = if let Some(name) = name {
297                name.clone()
298            } else {
299                let state = state.lock().await;
300                select_profile_name(&state, "Choose a profile to delete")?
301            };
302
303            let confirmed = if let Some(confirm) = confirm {
304                *confirm
305            } else {
306                Confirm::with_theme(&ColorfulTheme::default())
307                    .with_prompt(format!(
308                        "Are you sure you want to delete the profile '{}'? ",
309                        name
310                    ))
311                    .interact()
312                    .unwrap_or(false)
313            };
314
315            if !confirmed {
316                println!("Profile deletion cancelled.");
317                return Ok(());
318            }
319
320            let mut state = state.lock().await;
321            state
322                .config
323                .profiles
324                .retain(|profile| profile.name != *name);
325            state.secrets.remove_profile(&name);
326
327            secrets::write_secrets(&SECRETS_LOCATION, &state.secrets)?;
328            persist(&state.config)
329        }
330        ConfigCommands::SetActiveProfile { name } => {
331            let mut state = state.lock().await;
332            let name: String = if let Some(name) = name {
333                name.clone()
334            } else {
335                select_profile_name(&state, "Choose a profile to set as active")?
336            };
337
338            if !state
339                .config
340                .profiles
341                .iter()
342                .any(|profile| profile.name == name)
343            {
344                return Err(OperationOutcomeError::error(
345                    IssueType::exception(),
346                    format!("Profile with name '{}' does not exist", name),
347                ));
348            }
349
350            state.config.active_profile = Some(name.to_string());
351
352            persist(&state.config)
353        }
354    }
355}