Skip to main content

haste_health/commands/
profile.rs

1//! `profile` command: manage named server connection profiles.
2//!
3//! This module only owns the CLI surface (argument parsing, prompts, output). The persisted
4//! schema lives in [`crate::cli::config`]; secrets in [`crate::cli::secrets`].
5//!
6//! Every value can be passed on the command line. Anything missing is prompted for in a
7//! terminal; outside one it falls back to its default or fails naming the option to pass,
8//! so scripts never hang on a prompt.
9
10use crate::cli::{
11    client::unix_now,
12    config::{
13        CliConfiguration, DEFAULT_CLIENT_CREDENTIALS_SCOPE, Profile, ProfileAuth, write_config,
14    },
15    secrets::{self, ProfileSecrets, StoredTokens},
16    state::{CONFIG_LOCATION, CliState, SECRETS_LOCATION},
17};
18use clap::{Args, Subcommand, ValueEnum};
19use dialoguer::{Confirm, Input, Password, Select, theme::ColorfulTheme};
20use haste_fhir_model::r4::generated::terminology::IssueType;
21use haste_fhir_operation_error::OperationOutcomeError;
22use std::{io::IsTerminal, sync::Arc};
23use tokio::sync::Mutex;
24
25const DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1:8976/callback";
26const DEFAULT_AUTHORIZATION_CODE_SCOPE: &str = "openid profile fhirUser offline_access user/*.*";
27
28const CREATE_HINT: &str = "Create one with `haste-health profile create`.";
29const USE_HINT: &str = "Pick one with `haste-health profile use`.";
30
31/// How the CLI authenticates for a profile.
32#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
33pub(crate) enum AuthModeChoice {
34    /// A human logs in through the browser (authorization_code + PKCE) with a public
35    /// client. Run `haste-health login` afterwards to sign in.
36    AuthorizationCode,
37    /// A confidential (server-to-server) client authenticated with a client secret.
38    ClientCredentials,
39    /// HTTP basic authentication with a username and password.
40    BasicAuth,
41    /// No authentication: requests are sent anonymously.
42    #[value(name = "none")]
43    NoAuth,
44}
45
46impl AuthModeChoice {
47    /// Order shown in the picker; the first is preselected.
48    const ALL: [AuthModeChoice; 4] = [
49        AuthModeChoice::AuthorizationCode,
50        AuthModeChoice::ClientCredentials,
51        AuthModeChoice::BasicAuth,
52        AuthModeChoice::NoAuth,
53    ];
54
55    fn of(auth: &ProfileAuth) -> Self {
56        match auth {
57            ProfileAuth::AuthorizationCode { .. } => AuthModeChoice::AuthorizationCode,
58            ProfileAuth::ClientCredentails { .. } => AuthModeChoice::ClientCredentials,
59            ProfileAuth::Basic { .. } => AuthModeChoice::BasicAuth,
60            ProfileAuth::Public {} => AuthModeChoice::NoAuth,
61        }
62    }
63
64    /// The `--auth-mode` value, e.g. `client-credentials`.
65    fn name(self) -> String {
66        self.to_possible_value()
67            .expect("no auth mode is skipped")
68            .get_name()
69            .to_string()
70    }
71
72    fn picker_label(self) -> &'static str {
73        match self {
74            AuthModeChoice::AuthorizationCode => "Authorization code (log in through the browser)",
75            AuthModeChoice::ClientCredentials => "Client credentials (client ID and secret)",
76            AuthModeChoice::BasicAuth => "Basic auth (username and password)",
77            AuthModeChoice::NoAuth => "None (anonymous requests)",
78        }
79    }
80
81    /// Whether the mode gets tokens from an OIDC provider, and so needs a discovery URI
82    /// and client ID.
83    fn uses_oidc(self) -> bool {
84        matches!(
85            self,
86            AuthModeChoice::AuthorizationCode | AuthModeChoice::ClientCredentials
87        )
88    }
89}
90
91#[derive(Args, Debug)]
92pub(crate) struct CreateArgs {
93    /// Profile name.
94    name: Option<String>,
95    /// Base URL of the FHIR R4 server.
96    #[arg(short, long)]
97    r4_url: Option<String>,
98    /// How the CLI authenticates for this profile.
99    #[arg(long, value_enum)]
100    auth_mode: Option<AuthModeChoice>,
101    /// OIDC discovery (`.well-known/openid-configuration`) URI. Used by authorization-code
102    /// and client-credentials.
103    #[arg(short, long)]
104    discovery_uri: Option<String>,
105    /// OAuth client ID. Used by authorization-code and client-credentials.
106    #[arg(long)]
107    client_id: Option<String>,
108    /// OAuth client secret, for client-credentials. Stored in the secrets file, not the
109    /// profile.
110    #[arg(long)]
111    client_secret: Option<String>,
112    /// Loopback redirect URI for authorization-code (must be registered on the client).
113    /// Defaults to `http://127.0.0.1:8976/callback`.
114    #[arg(long)]
115    redirect_uri: Option<String>,
116    /// OAuth scope to request. Defaults to `openid profile fhirUser offline_access user/*.*`
117    /// for authorization-code and `openid system/*.*` for client-credentials.
118    #[arg(long)]
119    scope: Option<String>,
120    /// Username, for basic-auth.
121    #[arg(long)]
122    username: Option<String>,
123    /// Password, for basic-auth. Stored in the secrets file, not the profile.
124    #[arg(long)]
125    password: Option<String>,
126}
127
128/// Manage named server connection profiles.
129#[derive(Subcommand, Debug)]
130pub(crate) enum ProfileCommands {
131    /// List all profiles. `*` marks the active one.
132    List,
133    /// Show a profile's settings (never secrets or tokens). Defaults to the active profile.
134    Show {
135        /// Profile name.
136        name: Option<String>,
137    },
138    /// Create a profile and make it active. Prompts for anything not passed as an option.
139    Create(CreateArgs),
140    /// Switch the active profile that other commands use. Without NAME, pick from a list.
141    Use {
142        /// Profile name.
143        name: Option<String>,
144    },
145    /// Delete a profile and its stored secrets. Without NAME, pick from a list.
146    Delete {
147        /// Profile name.
148        name: Option<String>,
149        /// Delete without asking for confirmation.
150        #[arg(short, long)]
151        yes: bool,
152    },
153}
154
155/// Fills in values that weren't passed on the command line.
156struct Prompter {
157    /// Whether there's a terminal to prompt in. Without one, a missing value falls back to
158    /// its default, or is an error naming the option to pass.
159    interactive: bool,
160    theme: ColorfulTheme,
161}
162
163impl Prompter {
164    fn new(interactive: bool) -> Self {
165        Prompter {
166            interactive,
167            theme: ColorfulTheme::default(),
168        }
169    }
170
171    fn text(
172        &self,
173        value: Option<&str>,
174        option: &str,
175        prompt: &str,
176        default: Option<&str>,
177    ) -> Result<String, OperationOutcomeError> {
178        if let Some(value) = value {
179            return Ok(value.to_string());
180        }
181        if !self.interactive {
182            return default.map(str::to_string).ok_or_else(|| missing(option));
183        }
184
185        let mut input = Input::<String>::with_theme(&self.theme).with_prompt(prompt);
186        if let Some(default) = default {
187            input = input.default(default.to_string());
188        }
189        input.interact_text().map_err(prompt_failed)
190    }
191
192    fn secret(
193        &self,
194        value: Option<&str>,
195        option: &str,
196        prompt: &str,
197    ) -> Result<String, OperationOutcomeError> {
198        if let Some(value) = value {
199            return Ok(value.to_string());
200        }
201        if !self.interactive {
202            return Err(missing(option));
203        }
204
205        Password::with_theme(&self.theme)
206            .with_prompt(prompt)
207            .interact()
208            .map_err(prompt_failed)
209    }
210
211    /// The index of the picked item; `default` is preselected.
212    fn select(
213        &self,
214        option: &str,
215        prompt: &str,
216        items: &[&str],
217        default: usize,
218    ) -> Result<usize, OperationOutcomeError> {
219        if !self.interactive {
220            return Err(missing(option));
221        }
222
223        Select::with_theme(&self.theme)
224            .with_prompt(prompt)
225            .items(items)
226            .default(default)
227            .interact()
228            .map_err(prompt_failed)
229    }
230
231    fn confirm(&self, option: &str, prompt: &str) -> Result<bool, OperationOutcomeError> {
232        if !self.interactive {
233            return Err(missing(option));
234        }
235
236        Confirm::with_theme(&self.theme)
237            .with_prompt(prompt)
238            .interact()
239            .map_err(prompt_failed)
240    }
241}
242
243fn error(message: impl Into<String>) -> OperationOutcomeError {
244    OperationOutcomeError::error(IssueType::invalid(), message.into())
245}
246
247fn missing(option: &str) -> OperationOutcomeError {
248    error(format!(
249        "Missing {option}. Pass it on the command line, or run in a terminal to be prompted for it."
250    ))
251}
252
253fn prompt_failed(e: dialoguer::Error) -> OperationOutcomeError {
254    OperationOutcomeError::error(IssueType::exception(), format!("Prompt failed: {e}"))
255}
256
257fn no_active_hint(config: &CliConfiguration) -> &'static str {
258    if config.profiles.is_empty() {
259        CREATE_HINT
260    } else {
261        USE_HINT
262    }
263}
264
265fn find<'a>(
266    config: &'a CliConfiguration,
267    name: &str,
268) -> Result<&'a Profile, OperationOutcomeError> {
269    config
270        .profiles
271        .iter()
272        .find(|profile| profile.name == name)
273        .ok_or_else(|| {
274            error(format!(
275                "No profile named '{name}'. Run `haste-health profile list` to see them."
276            ))
277        })
278}
279
280/// The named profile, or one picked from a list when no name was given.
281fn pick<'a>(
282    config: &'a CliConfiguration,
283    name: Option<&str>,
284    prompter: &Prompter,
285    prompt: &str,
286) -> Result<&'a Profile, OperationOutcomeError> {
287    if let Some(name) = name {
288        return find(config, name);
289    }
290    if config.profiles.is_empty() {
291        return Err(error(format!("No profiles yet. {CREATE_HINT}")));
292    }
293
294    let names: Vec<&str> = config.profiles.iter().map(|p| p.name.as_str()).collect();
295    let active = config
296        .active_profile
297        .as_deref()
298        .and_then(|active| names.iter().position(|name| *name == active))
299        .unwrap_or(0);
300    let index = prompter.select("the profile name", prompt, &names, active)?;
301
302    Ok(&config.profiles[index])
303}
304
305/// Builds the profile `create` saves, plus the secret (client secret or password) that
306/// goes in the secrets file rather than the profile.
307fn build_profile(
308    name: String,
309    args: &CreateArgs,
310    prompter: &Prompter,
311) -> Result<(Profile, Option<String>), OperationOutcomeError> {
312    let r4_url = prompter.text(
313        args.r4_url.as_deref(),
314        "--r4-url",
315        "FHIR R4 server URL",
316        None,
317    )?;
318
319    let auth_mode = match args.auth_mode {
320        Some(mode) => mode,
321        None => {
322            let labels = AuthModeChoice::ALL.map(AuthModeChoice::picker_label);
323            AuthModeChoice::ALL[prompter.select("--auth-mode", "Auth mode", &labels, 0)?]
324        }
325    };
326
327    // Basic auth and anonymous profiles never fetch the discovery document, so it's only
328    // kept if passed.
329    let oidc_discovery_uri = if auth_mode.uses_oidc() {
330        prompter.text(
331            args.discovery_uri.as_deref(),
332            "--discovery-uri",
333            "OIDC discovery URI",
334            None,
335        )?
336    } else {
337        args.discovery_uri.clone().unwrap_or_default()
338    };
339
340    let client_id = || {
341        prompter.text(
342            args.client_id.as_deref(),
343            "--client-id",
344            "OAuth client ID",
345            None,
346        )
347    };
348
349    let (auth, secret) = match auth_mode {
350        AuthModeChoice::AuthorizationCode => (
351            ProfileAuth::AuthorizationCode {
352                client_id: client_id()?,
353                redirect_uri: prompter.text(
354                    args.redirect_uri.as_deref(),
355                    "--redirect-uri",
356                    "Loopback redirect URI",
357                    Some(DEFAULT_REDIRECT_URI),
358                )?,
359                scope: prompter.text(
360                    args.scope.as_deref(),
361                    "--scope",
362                    "OAuth scope",
363                    Some(DEFAULT_AUTHORIZATION_CODE_SCOPE),
364                )?,
365            },
366            None,
367        ),
368        AuthModeChoice::ClientCredentials => {
369            let client_id = client_id()?;
370            let client_secret = prompter.secret(
371                args.client_secret.as_deref(),
372                "--client-secret",
373                "OAuth client secret",
374            )?;
375            let scope = prompter.text(
376                args.scope.as_deref(),
377                "--scope",
378                "OAuth scope",
379                Some(DEFAULT_CLIENT_CREDENTIALS_SCOPE),
380            )?;
381
382            (
383                ProfileAuth::ClientCredentails {
384                    client_id,
385                    // Unset means "the default", which is what the client falls back to.
386                    scope: (scope != DEFAULT_CLIENT_CREDENTIALS_SCOPE).then_some(scope),
387                },
388                Some(client_secret),
389            )
390        }
391        AuthModeChoice::BasicAuth => (
392            ProfileAuth::Basic {
393                username: prompter.text(
394                    args.username.as_deref(),
395                    "--username",
396                    "Username",
397                    None,
398                )?,
399            },
400            Some(prompter.secret(args.password.as_deref(), "--password", "Password")?),
401        ),
402        AuthModeChoice::NoAuth => (ProfileAuth::Public {}, None),
403    };
404
405    Ok((
406        Profile {
407            name,
408            r4_url,
409            oidc_discovery_uri,
410            auth,
411        },
412        secret,
413    ))
414}
415
416/// Options that were passed but that `mode` doesn't use.
417fn ignored_options(args: &CreateArgs, mode: AuthModeChoice) -> Vec<&'static str> {
418    let given = |value: &Option<String>| value.as_deref().is_some_and(|v| !v.is_empty());
419
420    [
421        ("--client-id", given(&args.client_id), mode.uses_oidc()),
422        (
423            "--client-secret",
424            given(&args.client_secret),
425            mode == AuthModeChoice::ClientCredentials,
426        ),
427        (
428            "--redirect-uri",
429            given(&args.redirect_uri),
430            mode == AuthModeChoice::AuthorizationCode,
431        ),
432        ("--scope", given(&args.scope), mode.uses_oidc()),
433        (
434            "--username",
435            given(&args.username),
436            mode == AuthModeChoice::BasicAuth,
437        ),
438        (
439            "--password",
440            given(&args.password),
441            mode == AuthModeChoice::BasicAuth,
442        ),
443    ]
444    .into_iter()
445    .filter(|(_, given, used)| *given && !used)
446    .map(|(option, ..)| option)
447    .collect()
448}
449
450fn create(
451    state: &mut CliState,
452    args: &CreateArgs,
453    prompter: &Prompter,
454) -> Result<(), OperationOutcomeError> {
455    let name = prompter.text(
456        args.name.as_deref(),
457        "the profile name",
458        "Profile name",
459        None,
460    )?;
461    if name.trim().is_empty() {
462        return Err(error("The profile name can't be empty."));
463    }
464    // Checked before the other prompts so nobody fills them all in for nothing.
465    if state.config.profiles.iter().any(|p| p.name == name) {
466        return Err(error(format!(
467            "A profile named '{name}' already exists. Delete it with `haste-health profile delete {name}` or pick another name."
468        )));
469    }
470
471    let (profile, secret) = build_profile(name.clone(), args, prompter)?;
472    let mode = AuthModeChoice::of(&profile.auth);
473    let ignored = ignored_options(args, mode);
474    if !ignored.is_empty() {
475        eprintln!(
476            "Ignored {}: not used by {} profiles.",
477            ignored.join(", "),
478            mode.name()
479        );
480    }
481
482    state.config.profiles.push(profile);
483    state.config.active_profile = Some(name.clone());
484
485    // The client secret or password goes in the secrets file, never the profile.
486    if let Some(secret) = secret {
487        state.secrets.profile_mut(&name).client_secret = Some(secret);
488        secrets::write_secrets(&SECRETS_LOCATION, &state.secrets)?;
489    }
490    write_config(&CONFIG_LOCATION, &state.config)?;
491
492    eprintln!("Created profile '{name}' and made it active.");
493    if mode == AuthModeChoice::AuthorizationCode {
494        eprintln!("Run `haste-health login` to sign in.");
495    }
496
497    Ok(())
498}
499
500fn delete(
501    state: &mut CliState,
502    name: Option<&str>,
503    yes: bool,
504    prompter: &Prompter,
505) -> Result<(), OperationOutcomeError> {
506    let name = pick(&state.config, name, prompter, "Profile to delete")?
507        .name
508        .clone();
509
510    let confirmed = yes
511        || prompter.confirm(
512            "--yes",
513            &format!("Delete profile '{name}' and its stored secrets?"),
514        )?;
515    if !confirmed {
516        eprintln!("Cancelled. Profile '{name}' was not deleted.");
517        return Ok(());
518    }
519
520    state.config.profiles.retain(|profile| profile.name != name);
521    let was_active = state.config.active_profile.as_deref() == Some(name.as_str());
522    if was_active {
523        state.config.active_profile = None;
524    }
525    state.secrets.remove_profile(&name);
526
527    secrets::write_secrets(&SECRETS_LOCATION, &state.secrets)?;
528    write_config(&CONFIG_LOCATION, &state.config)?;
529
530    eprintln!("Deleted profile '{name}'.");
531    if was_active {
532        eprintln!(
533            "No profile is active now. {}",
534            no_active_hint(&state.config)
535        );
536    }
537
538    Ok(())
539}
540
541fn print_list(config: &CliConfiguration) {
542    if config.profiles.is_empty() {
543        eprintln!("No profiles yet. {CREATE_HINT}");
544        return;
545    }
546
547    let rows: Vec<(&str, String, &str)> = config
548        .profiles
549        .iter()
550        .map(|p| {
551            (
552                p.name.as_str(),
553                AuthModeChoice::of(&p.auth).name(),
554                p.r4_url.as_str(),
555            )
556        })
557        .collect();
558    let name_width = rows
559        .iter()
560        .map(|r| r.0.len())
561        .chain(["NAME".len()])
562        .max()
563        .unwrap_or(0);
564    let auth_width = rows
565        .iter()
566        .map(|r| r.1.len())
567        .chain(["AUTH".len()])
568        .max()
569        .unwrap_or(0);
570
571    println!(
572        "  {:<name_width$}  {:<auth_width$}  FHIR R4 URL",
573        "NAME", "AUTH"
574    );
575    for (name, auth, url) in &rows {
576        let marker = if config.active_profile.as_deref() == Some(*name) {
577            '*'
578        } else {
579            ' '
580        };
581        println!("{marker} {name:<name_width$}  {auth:<auth_width$}  {url}");
582    }
583}
584
585fn print_profile(profile: &Profile, active: bool, secrets: Option<&ProfileSecrets>) {
586    let mode = AuthModeChoice::of(&profile.auth);
587    let has_secret = secrets.and_then(|s| s.client_secret.as_ref()).is_some();
588
589    let mut rows = vec![
590        (
591            "Name",
592            if active {
593                format!("{} (active)", profile.name)
594            } else {
595                profile.name.clone()
596            },
597        ),
598        ("FHIR R4 URL", profile.r4_url.clone()),
599        ("Auth mode", mode.name()),
600    ];
601    if mode.uses_oidc() || !profile.oidc_discovery_uri.is_empty() {
602        rows.push(("Discovery URI", profile.oidc_discovery_uri.clone()));
603    }
604
605    match &profile.auth {
606        ProfileAuth::AuthorizationCode {
607            client_id,
608            redirect_uri,
609            scope,
610        } => {
611            rows.push(("Client ID", client_id.clone()));
612            rows.push(("Redirect URI", redirect_uri.clone()));
613            rows.push(("Scope", scope.clone()));
614            rows.push((
615                "Login",
616                login_status(secrets.and_then(|s| s.tokens.as_ref())),
617            ));
618        }
619        ProfileAuth::ClientCredentails { client_id, scope } => {
620            rows.push(("Client ID", client_id.clone()));
621            rows.push((
622                "Scope",
623                scope
624                    .clone()
625                    .unwrap_or_else(|| DEFAULT_CLIENT_CREDENTIALS_SCOPE.to_string()),
626            ));
627            rows.push(("Client secret", secret_status(has_secret)));
628        }
629        ProfileAuth::Basic { username } => {
630            rows.push(("Username", username.clone()));
631            rows.push(("Password", secret_status(has_secret)));
632        }
633        ProfileAuth::Public {} => {}
634    }
635
636    let width = rows.iter().map(|(label, _)| label.len()).max().unwrap_or(0);
637    for (label, value) in rows {
638        println!("{label:<width$}  {value}");
639    }
640}
641
642fn secret_status(stored: bool) -> String {
643    if stored {
644        format!("stored in {}", SECRETS_LOCATION.display())
645    } else {
646        "missing; delete and recreate the profile to set it".to_string()
647    }
648}
649
650fn login_status(tokens: Option<&StoredTokens>) -> String {
651    let Some(tokens) = tokens else {
652        return "not logged in; run `haste-health login`".to_string();
653    };
654
655    let remaining = tokens.expires_at - unix_now();
656    if remaining > 0 {
657        format!(
658            "logged in, access token valid for {}",
659            format_duration(remaining)
660        )
661    } else if tokens.refresh_token.is_some() {
662        "logged in, access token refreshes on next use".to_string()
663    } else {
664        "session expired; run `haste-health login`".to_string()
665    }
666}
667
668fn format_duration(seconds: i64) -> String {
669    match seconds / 60 {
670        0 => "under a minute".to_string(),
671        minutes @ 1..60 => format!("{minutes} min"),
672        minutes => format!("{} h {} min", minutes / 60, minutes % 60),
673    }
674}
675
676/// Runs the `profile` command group.
677pub(crate) async fn run(
678    state: Arc<Mutex<CliState>>,
679    command: &ProfileCommands,
680) -> Result<(), OperationOutcomeError> {
681    let prompter = Prompter::new(std::io::stdin().is_terminal() && std::io::stderr().is_terminal());
682    let mut state = state.lock().await;
683
684    match command {
685        ProfileCommands::List => {
686            print_list(&state.config);
687            Ok(())
688        }
689        ProfileCommands::Show { name } => {
690            let profile = match name.as_deref() {
691                Some(name) => find(&state.config, name)?,
692                None => match state.config.current_profile() {
693                    Some(profile) => profile,
694                    None => {
695                        eprintln!("No active profile. {}", no_active_hint(&state.config));
696                        return Ok(());
697                    }
698                },
699            };
700            let active = state.config.active_profile.as_deref() == Some(profile.name.as_str());
701            print_profile(profile, active, state.secrets.profile(&profile.name));
702            Ok(())
703        }
704        ProfileCommands::Create(args) => create(&mut state, args, &prompter),
705        ProfileCommands::Use { name } => {
706            let name = pick(&state.config, name.as_deref(), &prompter, "Profile to use")?
707                .name
708                .clone();
709            state.config.active_profile = Some(name.clone());
710            write_config(&CONFIG_LOCATION, &state.config)?;
711            eprintln!("Now using profile '{name}'.");
712            Ok(())
713        }
714        ProfileCommands::Delete { name, yes } => {
715            delete(&mut state, name.as_deref(), *yes, &prompter)
716        }
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use crate::{Cli, CliCommand};
724    use clap::{CommandFactory, Parser};
725
726    fn parse(args: &[&str]) -> Result<ProfileCommands, clap::Error> {
727        let cli = Cli::try_parse_from(std::iter::once("haste-health").chain(args.iter().copied()))?;
728        match cli.command {
729            CliCommand::Profile { command } => Ok(command),
730            _ => panic!("not a profile command: {args:?}"),
731        }
732    }
733
734    fn create_args(args: &[&str]) -> CreateArgs {
735        match parse(args).unwrap() {
736            ProfileCommands::Create(args) => args,
737            other => panic!("expected create, got {other:?}"),
738        }
739    }
740
741    /// Runs `create`'s resolution as it would outside a terminal.
742    fn build(args: &[&str]) -> Result<(Profile, Option<String>), OperationOutcomeError> {
743        let args = create_args(args);
744        let name = args.name.clone().unwrap();
745        build_profile(name, &args, &Prompter::new(false))
746    }
747
748    #[test]
749    fn cli_definition_is_valid() {
750        Cli::command().debug_assert();
751    }
752
753    #[test]
754    fn create_builds_each_auth_mode() {
755        let (profile, secret) = build(&[
756            "profile",
757            "create",
758            "browser",
759            "--r4-url",
760            "http://localhost:3000/fhir",
761            "--auth-mode",
762            "authorization-code",
763            "--discovery-uri",
764            "http://localhost:3000/.well-known/openid-configuration",
765            "--client-id",
766            "cli",
767        ])
768        .unwrap();
769        // Outside a terminal, options with defaults fall back to them.
770        assert!(matches!(
771            profile.auth,
772            ProfileAuth::AuthorizationCode { ref client_id, ref redirect_uri, ref scope }
773                if client_id == "cli"
774                    && redirect_uri == DEFAULT_REDIRECT_URI
775                    && scope == DEFAULT_AUTHORIZATION_CODE_SCOPE
776        ));
777        assert_eq!(secret, None);
778
779        let (profile, secret) = build(&[
780            "profile",
781            "create",
782            "service",
783            "--r4-url",
784            "http://localhost:3000/fhir",
785            "--auth-mode",
786            "client-credentials",
787            "--discovery-uri",
788            "http://localhost:3000/.well-known/openid-configuration",
789            "--client-id",
790            "worker",
791            "--client-secret",
792            "s3cret",
793        ])
794        .unwrap();
795        // The default scope is stored as unset, which the client reads as the default.
796        assert!(matches!(
797            profile.auth,
798            ProfileAuth::ClientCredentails { ref client_id, scope: None } if client_id == "worker"
799        ));
800        assert_eq!(secret.as_deref(), Some("s3cret"));
801
802        let (profile, secret) = build(&[
803            "profile",
804            "create",
805            "basic",
806            "--r4-url",
807            "http://localhost:3000/fhir",
808            "--auth-mode",
809            "basic-auth",
810            "--username",
811            "alice",
812            "--password",
813            "hunter2",
814        ])
815        .unwrap();
816        assert!(matches!(profile.auth, ProfileAuth::Basic { ref username } if username == "alice"));
817        assert_eq!(secret.as_deref(), Some("hunter2"));
818
819        let (profile, secret) = build(&[
820            "profile",
821            "create",
822            "anonymous",
823            "--r4-url",
824            "http://localhost:3000/fhir",
825            "--auth-mode",
826            "none",
827        ])
828        .unwrap();
829        assert!(matches!(profile.auth, ProfileAuth::Public {}));
830        assert_eq!(secret, None);
831    }
832
833    #[test]
834    fn missing_values_name_the_option_outside_a_terminal() {
835        let error = build(&["profile", "create", "x", "--r4-url", "http://localhost"])
836            .unwrap_err()
837            .to_string();
838        assert!(error.contains("--auth-mode"), "{error}");
839
840        let error = build(&[
841            "profile",
842            "create",
843            "x",
844            "--r4-url",
845            "http://localhost",
846            "--auth-mode",
847            "client-credentials",
848            "--discovery-uri",
849            "http://localhost/.well-known/openid-configuration",
850            "--client-id",
851            "worker",
852        ])
853        .unwrap_err()
854        .to_string();
855        assert!(error.contains("--client-secret"), "{error}");
856    }
857
858    #[test]
859    fn options_the_auth_mode_does_not_use_are_reported() {
860        let args = create_args(&[
861            "profile",
862            "create",
863            "x",
864            "--auth-mode",
865            "authorization-code",
866            "--client-id",
867            "cli",
868            "--client-secret",
869            "unused",
870            "--password",
871            "unused",
872        ]);
873        assert_eq!(
874            ignored_options(&args, AuthModeChoice::AuthorizationCode),
875            ["--client-secret", "--password"]
876        );
877    }
878
879    #[test]
880    fn name_is_positional() {
881        let ProfileCommands::Use { name } = parse(&["profile", "use", "staging"]).unwrap() else {
882            panic!("expected use");
883        };
884        assert_eq!(name.as_deref(), Some("staging"));
885
886        let ProfileCommands::Delete { name, yes } =
887            parse(&["profile", "delete", "staging", "-y"]).unwrap()
888        else {
889            panic!("expected delete");
890        };
891        assert_eq!(name.as_deref(), Some("staging"));
892        assert!(yes);
893    }
894
895    #[test]
896    fn durations_read_naturally() {
897        assert_eq!(format_duration(30), "under a minute");
898        assert_eq!(format_duration(42 * 60), "42 min");
899        assert_eq!(format_duration(2 * 3600 + 5 * 60), "2 h 5 min");
900    }
901}