1use 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#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
33pub(crate) enum AuthModeChoice {
34 AuthorizationCode,
37 ClientCredentials,
39 BasicAuth,
41 #[value(name = "none")]
43 NoAuth,
44}
45
46impl AuthModeChoice {
47 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 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 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 name: Option<String>,
95 #[arg(short, long)]
97 r4_url: Option<String>,
98 #[arg(long, value_enum)]
100 auth_mode: Option<AuthModeChoice>,
101 #[arg(short, long)]
104 discovery_uri: Option<String>,
105 #[arg(long)]
107 client_id: Option<String>,
108 #[arg(long)]
111 client_secret: Option<String>,
112 #[arg(long)]
115 redirect_uri: Option<String>,
116 #[arg(long)]
119 scope: Option<String>,
120 #[arg(long)]
122 username: Option<String>,
123 #[arg(long)]
125 password: Option<String>,
126}
127
128#[derive(Subcommand, Debug)]
130pub(crate) enum ProfileCommands {
131 List,
133 Show {
135 name: Option<String>,
137 },
138 Create(CreateArgs),
140 Use {
142 name: Option<String>,
144 },
145 Delete {
147 name: Option<String>,
149 #[arg(short, long)]
151 yes: bool,
152 },
153}
154
155struct Prompter {
157 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 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
280fn 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
305fn 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 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 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
416fn 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 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 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
676pub(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 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 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 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}