use std::future::Future;
use std::pin::Pin;
use anyhow::Context;
use ratatui::DefaultTerminal;
use crate::cli_runtime::CliExecutable;
use super::{external_editor, App, Entry};
pub(super) type QueryFuture<S, E> = Pin<Box<dyn Future<Output = Result<S, E>> + Send>>;
pub(super) type AfterSuccessFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
#[derive(Clone, Copy)]
pub(super) enum LoginConfirm<S, E> {
Prompt {
prompt_unsigned: fn(&mut App),
prompt_signed_in: fn(&mut App, S),
failed_status: &'static str,
is_binary_missing: fn(&E) -> bool,
},
Direct,
}
#[derive(Clone, Copy)]
pub(super) enum CompleteAnnouncement {
StatusOnly,
NoticeAndStatus,
}
pub(super) struct LoginAuthCopy<S, E> {
pub status_line_prefix: &'static str,
pub signed_in_notice: fn(&S) -> String,
pub incomplete_signed_out: fn(&S) -> String,
pub incomplete_query_error: fn(&E) -> String,
pub failed: fn(&anyhow::Error) -> String,
pub child_failed_but_signed_in: fn(&anyhow::Error, &S) -> String,
}
pub(super) struct ExternalLoginSpec<S, E> {
pub command_label: &'static str,
pub resolve: fn() -> Result<CliExecutable, E>,
pub login_args: &'static [&'static str],
pub query: fn() -> QueryFuture<S, E>,
pub is_signed_in: fn(&S) -> bool,
pub copy: LoginAuthCopy<S, E>,
pub confirm: LoginConfirm<S, E>,
pub handoff_notice: &'static str,
pub handoff_status: String,
pub complete_announcement: CompleteAnnouncement,
pub after_success: Option<fn(&S) -> AfterSuccessFuture>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum LoginAuthOutcome {
Complete { notice: String },
Incomplete { message: String },
Failed { message: String },
}
#[derive(Debug)]
enum LoginAfterSuspend<S> {
ResumeFailed {
error: anyhow::Error,
},
AuthResolved {
outcome: LoginAuthOutcome,
status: Option<S>,
},
}
impl App {
pub(super) async fn start_external_login<S, E>(
&mut self,
terminal: &mut DefaultTerminal,
spec: ExternalLoginSpec<S, E>,
) -> anyhow::Result<()>
where
E: std::error::Error + Send + Sync + 'static,
{
match spec.confirm {
LoginConfirm::Direct => self.run_external_login(terminal, spec).await,
LoginConfirm::Prompt {
prompt_unsigned,
prompt_signed_in,
failed_status,
is_binary_missing,
} => match (spec.query)().await {
Ok(status) if (spec.is_signed_in)(&status) => {
prompt_signed_in(self, status);
Ok(())
}
Ok(_) => {
prompt_unsigned(self);
Ok(())
}
Err(error) if is_binary_missing(&error) => {
prompt_unsigned(self);
Ok(())
}
Err(error) => {
self.insert_entry(&Entry::Error(error.to_string()));
self.set_status(failed_status);
Ok(())
}
},
}
}
pub(super) async fn run_external_login<S, E>(
&mut self,
terminal: &mut DefaultTerminal,
spec: ExternalLoginSpec<S, E>,
) -> anyhow::Result<()>
where
E: std::error::Error + Send + Sync + 'static,
{
self.insert_entry(&Entry::Notice(spec.handoff_notice.into()));
terminal.draw(|frame| self.draw(frame))?;
let mut terminal_session = self
.terminal_session
.take()
.context("terminal session is unavailable")?;
let resolve = spec.resolve;
let login_args = spec.login_args;
let command_label = spec.command_label;
let handoff_status = spec.handoff_status.clone();
let suspended_run = terminal_session
.run_suspended(terminal, &handoff_status, || async move {
let executable = resolve().map_err(anyhow::Error::new)?;
let mut command = executable
.try_command(login_args.iter().copied())
.map_err(anyhow::Error::new)?;
command
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
#[cfg(unix)]
let _signal_guard =
external_editor::unix_suspended_child_signals::SuspendedChildSignalGuard::install(
&mut command,
)
.with_context(|| {
format!("could not prepare {command_label} signal handling")
})?;
let status = command.status().await.map_err(|source| {
anyhow::Error::from(source)
.context(format!("could not start {command_label}"))
})?;
if !status.success() {
return Err(anyhow::anyhow!("{command_label} exited with {status}"));
}
Ok(())
})
.await;
self.terminal_session = Some(terminal_session);
match resolve_login_after_suspend(
suspended_run.resume_result,
suspended_run.operation_result,
spec.query,
spec.is_signed_in,
&spec.copy,
spec.command_label,
)
.await
{
LoginAfterSuspend::ResumeFailed { error } => return Err(error),
LoginAfterSuspend::AuthResolved { outcome, status } => {
self.record_login_auth_outcome(
&outcome,
spec.copy.status_line_prefix,
spec.complete_announcement,
);
if let (LoginAuthOutcome::Complete { .. }, Some(status), Some(after_success)) =
(&outcome, status.as_ref(), spec.after_success)
{
after_success(status).await;
}
}
}
self.ctrl_c_streak = 0;
self.input_ui.clear_paste_burst();
Ok(())
}
fn record_login_auth_outcome(
&mut self,
outcome: &LoginAuthOutcome,
status_line_prefix: &'static str,
announcement: CompleteAnnouncement,
) {
match outcome {
LoginAuthOutcome::Complete { notice } => {
if matches!(announcement, CompleteAnnouncement::NoticeAndStatus) {
self.insert_entry(&Entry::Notice(notice.clone()));
}
self.set_status(notice);
}
LoginAuthOutcome::Incomplete { message } | LoginAuthOutcome::Failed { message } => {
self.insert_entry(&Entry::Error(message.clone()));
let suffix = match outcome {
LoginAuthOutcome::Incomplete { .. } => "incomplete",
LoginAuthOutcome::Failed { .. } => "failed",
LoginAuthOutcome::Complete { .. } => unreachable!(),
};
self.set_status(format!("{status_line_prefix} {suffix}"));
}
}
}
}
async fn resolve_login_after_suspend<S, E, F, Fut>(
resume_result: Result<(), anyhow::Error>,
operation_result: Result<(), anyhow::Error>,
query: F,
is_signed_in: fn(&S) -> bool,
copy: &LoginAuthCopy<S, E>,
command_label: &'static str,
) -> LoginAfterSuspend<S>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<S, E>>,
{
if let Err(resume_error) = resume_result {
let error = match operation_result {
Ok(()) => resume_error,
Err(operation_error) => {
resume_error.context(format!("{command_label} also failed: {operation_error:#}"))
}
};
return LoginAfterSuspend::ResumeFailed { error };
}
let (outcome, status) =
resolve_login_auth_outcome(operation_result, query, is_signed_in, copy).await;
LoginAfterSuspend::AuthResolved { outcome, status }
}
async fn resolve_login_auth_outcome<S, E, F, Fut>(
operation_result: Result<(), anyhow::Error>,
query: F,
is_signed_in: fn(&S) -> bool,
copy: &LoginAuthCopy<S, E>,
) -> (LoginAuthOutcome, Option<S>)
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<S, E>>,
{
match operation_result {
Ok(()) => match query().await {
Ok(status) if is_signed_in(&status) => {
let notice = (copy.signed_in_notice)(&status);
(LoginAuthOutcome::Complete { notice }, Some(status))
}
Ok(status) => (
LoginAuthOutcome::Incomplete {
message: (copy.incomplete_signed_out)(&status),
},
Some(status),
),
Err(error) => (
LoginAuthOutcome::Incomplete {
message: (copy.incomplete_query_error)(&error),
},
None,
),
},
Err(error) => match query().await {
Ok(status) if is_signed_in(&status) => {
let notice = (copy.child_failed_but_signed_in)(&error, &status);
(LoginAuthOutcome::Complete { notice }, Some(status))
}
Ok(status) => (
LoginAuthOutcome::Failed {
message: (copy.failed)(&error),
},
Some(status),
),
Err(_) => (
LoginAuthOutcome::Failed {
message: (copy.failed)(&error),
},
None,
),
},
}
}
#[cfg(test)]
#[path = "external_login_tests.rs"]
mod tests;