use std::path::Path;
use anyhow::{Result, bail};
use super::{Outside, suggested_address};
use crate::config::{
ConsoleAccess, ConsoleSettings, NotificationAddresses, UserConfig, UserConfigChanges,
};
use crate::email::DEFAULT_FROM;
use crate::harness::{Harness, ModelAndEffort};
use crate::resend_key::{self, Source};
pub struct Answers {
pub console: Option<ConsoleAccess>,
pub harness: Option<(Harness, ModelAndEffort)>,
pub merge_always: bool,
pub base_fix: bool,
pub launch_pull: bool,
pub security_fix: bool,
pub security_review: bool,
pub notifications: Option<Notifications>,
}
impl Answers {
pub fn changes(&self) -> UserConfigChanges {
UserConfigChanges {
console: self.console.clone(),
merge_always: self.merge_always,
base_fix: self.base_fix,
launch_pull: self.launch_pull,
security_fix: self.security_fix,
security_review: self.security_review,
notifications: self
.notifications
.as_ref()
.map(|notifications| NotificationAddresses {
to: notifications.to.clone(),
from: notifications.from.clone(),
}),
harness: self.harness.clone(),
}
}
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 offer(outside: &mut impl Outside, path: &Path) -> Result<bool> {
yes_or_no(
outside,
&format!(
"No User config at {}. Set your defaults now?",
path.display()
),
true,
)
}
pub fn ask(
outside: &mut impl Outside,
current: &UserConfig,
found: Option<Source>,
) -> Result<Answers> {
let harness = outside.ask_harness_settings(¤t.harness)?;
let merge_always = yes_or_no(outside, "Every Run a Merge run?", current.merge_always)?;
let base_fix = yes_or_no(
outside,
"Every Run may start a Base fix when the Base branch's CI is red?",
current.base_fix,
)?;
let launch_pull = yes_or_no(
outside,
"Every Run first fast-forwards your checkout of the Base branch?",
current.launch_pull,
)?;
let security_fix = yes_or_no(
outside,
"Security runs may fix reproduced findings?",
current.security_fix.unwrap_or(false),
)?;
let security_review = yes_or_no(
outside,
"Runs review their change for Security findings?",
current.security_review,
)?;
let notifications = ask_notifications(outside, current, found)?;
let console = ask_console(
outside,
¤t.console,
notifications
.as_ref()
.map(|email| email.to.as_str())
.or(current.email.to.as_deref()),
)?;
Ok(Answers {
harness,
merge_always,
base_fix,
launch_pull,
security_fix,
security_review,
notifications,
console,
})
}
fn ask_notifications(
outside: &mut impl Outside,
current: &UserConfig,
found: Option<Source>,
) -> Result<Option<Notifications>> {
if !yes_or_no(
outside,
"Run notifications, an email as each Run ends?",
current.email.always,
)? {
return Ok(None);
}
let suggested = match ¤t.email.to {
Some(to) => Some(to.clone()),
None => suggested_address(outside),
};
let to = loop {
let to = answer(outside, "Send Run notifications to", suggested.as_deref())?;
if to.contains('@') {
break to;
}
};
let from = answer(
outside,
"Send them from",
Some(current.email.from.as_deref().unwrap_or(DEFAULT_FROM)),
)?;
let key = match &found {
Some(Source::Environment) => {
outside.say("The Resend API key comes from RESEND_API_KEY.".to_string());
None
}
Some(Source::Credentials(_)) => ask_key(outside, "Enter keeps the saved one")?,
None => ask_key(outside, "Enter to skip")?,
};
let send_test = if found.is_some() || key.is_some() {
yes_or_no(outside, "Send a test email now?", false)?
} else {
outside.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."
.to_string(),
);
false
};
Ok(Some(Notifications {
to,
from,
key,
send_test,
}))
}
fn ask_console(
outside: &mut impl Outside,
current: &ConsoleSettings,
email_to: Option<&str>,
) -> Result<Option<ConsoleAccess>> {
if !yes_or_no(outside, "Run the Console?", !current.address.is_empty())? {
return Ok(None);
}
let address = loop {
let address = answer(
outside,
"Console's whole address (e.g. thirdshift.example.com)",
(!current.address.is_empty()).then_some(current.address.as_str()),
)?;
let settings = ConsoleSettings {
address: address.clone(),
..ConsoleSettings::default()
};
match settings.hostname() {
Ok(_) => break address,
Err(error) => outside.say(error.to_string()),
}
};
let saved_emails = current.allowed_emails.join(", ");
let default_emails = if saved_emails.is_empty() {
email_to
} else {
Some(saved_emails.as_str())
};
let allowed_emails = loop {
let emails = answer(outside, "Allowed emails (comma-separated)", default_emails)?;
let settings = ConsoleSettings {
allowed_emails: emails
.split(',')
.map(|email| email.trim().to_string())
.collect(),
..ConsoleSettings::default()
};
match settings.validate() {
Ok(()) => break settings.allowed_emails,
Err(error) => outside.say(error.to_string()),
}
};
Ok(Some(ConsoleAccess {
address,
allowed_emails,
}))
}
fn yes_or_no(outside: &mut impl Outside, question: &str, default: bool) -> Result<bool> {
let choices = if default { "[Y/n]" } else { "[y/N]" };
loop {
match read(outside, &format!("{question} {choices} "))?
.to_ascii_lowercase()
.as_str()
{
"" => return Ok(default),
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => {}
}
}
}
fn answer(
outside: &mut (impl Outside + ?Sized),
question: &str,
default: Option<&str>,
) -> Result<String> {
let prompt = match default {
Some(default) => format!("{question} [{default}]: "),
None => format!("{question}: "),
};
loop {
match (read(outside, &prompt)?, default) {
(answer, _) if !answer.is_empty() => return Ok(answer),
(_, Some(default)) => return Ok(default.to_string()),
(_, None) => {}
}
}
}
fn ask_key(outside: &mut impl Outside, on_enter: &str) -> Result<Option<String>> {
let prompt = format!("Resend API key (input hidden, {on_enter}): ");
loop {
match outside.read_hidden(&prompt)? {
None => bail!(ENDED),
Some(key) if key.is_empty() => return Ok(None),
Some(key) if resend_key::is_key(&key) => return Ok(Some(key)),
Some(_) => {
outside.say("That isn't a Resend API key, which starts with re_.".to_string())
}
}
}
}
fn read(outside: &mut (impl Outside + ?Sized), prompt: &str) -> Result<String> {
match outside.read(prompt)? {
Some(line) => Ok(line),
None => bail!(ENDED),
}
}
const ENDED: &str = "Setup ended before its last answer; nothing written";