use std::io::{BufRead, IsTerminal, Write};
use std::path::Path;
use anyhow::{Context, Result, bail};
use signal_hook::SigId;
use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM};
use signal_hook::low_level;
use crate::config::UserConfig;
use crate::email::DEFAULT_FROM;
use crate::resend_key::{self, Credentials, Source};
pub struct Answers {
pub merge_always: bool,
pub base_fix: bool,
pub launch_pull: bool,
pub notifications: Option<Notifications>,
}
impl Answers {
pub fn send_test(&self) -> bool {
self.notifications
.as_ref()
.is_some_and(|notifications| notifications.send_test)
}
pub fn key(&self) -> Option<&str> {
self.notifications
.as_ref()
.and_then(|notifications| notifications.key.as_deref())
}
}
pub struct Notifications {
pub to: String,
pub from: String,
pub key: Option<String>,
pub send_test: bool,
}
pub fn has_terminal() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
pub fn offer(path: &Path) -> Result<bool> {
yes_or_no(
&format!(
"No User config at {}. Set your defaults now?",
path.display()
),
true,
)
}
pub fn ask(
current: &UserConfig,
credentials: &Credentials,
suggested_address: impl FnOnce() -> Option<String>,
) -> Result<Answers> {
let merge_always = yes_or_no("Every Run a Merge run?", current.merge_always)?;
let base_fix = merge_always
&& yes_or_no(
"Every Run may start a Base fix when the Base branch's CI is red?",
current.base_fix,
)?;
let launch_pull = yes_or_no(
"Every Run first fast-forwards your checkout of the Base branch?",
current.launch_pull,
)?;
if !yes_or_no(
"Run notifications, an email as each Run ends?",
current.email.always,
)? {
return Ok(Answers {
merge_always,
base_fix,
launch_pull,
notifications: None,
});
}
let suggested = current.email.to.clone().or_else(suggested_address);
let to = loop {
let to = answer("Send Run notifications to", suggested.as_deref())?;
if to.contains('@') {
break to;
}
};
let from = answer(
"Send them from",
Some(current.email.from.as_deref().unwrap_or(DEFAULT_FROM)),
)?;
let found = credentials.lookup().map(|key| key.source);
let key = match found {
Some(Source::Environment) => {
say("The Resend API key comes from RESEND_API_KEY.");
None
}
Some(Source::Credentials(_)) => ask_key("Enter keeps the saved one")?,
None => ask_key("Enter to skip")?,
};
let send_test = if found.is_some() || key.is_some() {
yes_or_no("Send a test email now?", false)?
} else {
say(
"No Resend API key, so no email can go yet. To add one later, rerun \
`thirdshift setup`, or set RESEND_API_KEY in the environment the Run starts from.",
);
false
};
Ok(Answers {
merge_always,
base_fix,
launch_pull,
notifications: Some(Notifications {
to,
from,
key,
send_test,
}),
})
}
fn yes_or_no(question: &str, default: bool) -> Result<bool> {
let choices = if default { "[Y/n]" } else { "[y/N]" };
loop {
match read(&format!("{question} {choices} "))?
.to_ascii_lowercase()
.as_str()
{
"" => return Ok(default),
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => {}
}
}
}
fn answer(question: &str, default: Option<&str>) -> Result<String> {
let prompt = match default {
Some(default) => format!("{question} [{default}]: "),
None => format!("{question}: "),
};
loop {
match (read(&prompt)?, default) {
(answer, _) if !answer.is_empty() => return Ok(answer),
(_, Some(default)) => return Ok(default.to_string()),
(_, None) => {}
}
}
}
fn ask_key(on_enter: &str) -> Result<Option<String>> {
loop {
match read_hidden(&format!("Resend API key (input hidden, {on_enter}): "))? {
key if key.is_empty() => return Ok(None),
key if resend_key::is_key(&key) => return Ok(Some(key)),
_ => say("That isn't a Resend API key, which starts with re_."),
}
}
}
fn read_hidden(prompt: &str) -> Result<String> {
let echo_off = EchoOff::new()?;
let line = read(prompt)?;
drop(echo_off);
let _ = writeln!(std::io::stderr());
Ok(line)
}
struct EchoOff {
saved: libc::termios,
handlers: Vec<SigId>,
}
impl EchoOff {
fn new() -> Result<Self> {
let mut saved: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(0, &mut saved) } != 0 {
return Err(std::io::Error::last_os_error())
.context("can't read the terminal's settings");
}
let mut echo_off = EchoOff {
saved,
handlers: Vec::new(),
};
for signal in [SIGINT, SIGTERM, SIGHUP] {
let handler = unsafe {
low_level::register(signal, move || {
libc::tcsetattr(0, libc::TCSANOW, &saved);
let _ = low_level::emulate_default_handler(signal);
})
}
.context("could not install the signal handler")?;
echo_off.handlers.push(handler);
}
let mut hidden = saved;
hidden.c_lflag &= !libc::ECHO;
if unsafe { libc::tcsetattr(0, libc::TCSANOW, &hidden) } != 0 {
return Err(std::io::Error::last_os_error())
.context("can't turn the terminal's echo off");
}
Ok(echo_off)
}
}
impl Drop for EchoOff {
fn drop(&mut self) {
unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) };
for handler in self.handlers.drain(..) {
low_level::unregister(handler);
}
}
}
fn read(prompt: &str) -> Result<String> {
let mut stderr = std::io::stderr();
let _ = write!(stderr, "{prompt}");
let _ = stderr.flush();
let mut line = String::new();
if std::io::stdin().lock().read_line(&mut line)? == 0 {
let _ = writeln!(stderr);
bail!("Setup ended before its last answer; nothing written");
}
Ok(line.trim().to_string())
}
fn say(text: &str) {
let _ = writeln!(std::io::stderr(), "{text}");
}