use std::path::Path;
use anyhow::{Result, bail};
use super::{Outside, suggested_address};
use crate::config::UserConfig;
use crate::email::DEFAULT_FROM;
use crate::harness::{self, Harness, ModelAndEffort};
use crate::resend_key::{self, Source};
pub struct Answers {
pub harness: Option<(Harness, ModelAndEffort)>,
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 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 = ask_harness(outside, ¤t.harness)?;
let merge_always = yes_or_no(outside, "Every Run a Merge run?", current.merge_always)?;
let base_fix = merge_always
&& 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,
)?;
if !yes_or_no(
outside,
"Run notifications, an email as each Run ends?",
current.email.always,
)? {
return Ok(Answers {
harness,
merge_always,
base_fix,
launch_pull,
notifications: 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(Answers {
harness,
merge_always,
base_fix,
launch_pull,
notifications: Some(Notifications {
to,
from,
key,
send_test,
}),
})
}
fn ask_harness(
outside: &mut impl Outside,
current: &harness::Settings,
) -> Result<Option<(Harness, ModelAndEffort)>> {
let installed: Vec<Harness> = Harness::ALL
.into_iter()
.filter(|harness| outside.installed(*harness))
.collect();
let harnesses: Vec<String> = Harness::ALL
.iter()
.map(|harness| {
if installed.contains(harness) {
harness.name().to_string()
} else {
format!("{} (not installed)", harness.name())
}
})
.collect();
let harnesses = harnesses.join(" or ");
let default = current
.default
.filter(|harness| installed.contains(harness))
.or_else(|| installed.first().copied());
let Some(default) = default else {
outside.say(format!(
"Harness for every Run's sessions: {harnesses}. Sessions can run on neither here, so \
the harness settings stay as they are; install claude or codex, then rerun \
`thirdshift setup`."
));
return Ok(None);
};
let harness = loop {
let name = answer(
outside,
&format!("Harness for every Run's sessions, {harnesses}"),
Some(default.name()),
)?;
match Harness::named(&name) {
None => outside.say(format!("Choose {}.", harness::NAMES)),
Some(harness) if !installed.contains(&harness) => {
outside.say(format!("{name} is not installed: it isn't on PATH."))
}
Some(harness) => break harness,
}
};
let set = current.of(harness);
let chosen = match harness {
Harness::Claude => ask_claude(outside, set)?,
Harness::Codex => match outside.codex_catalog() {
Ok(catalog) => ask_codex(outside, &catalog, set)?,
Err(error) => {
outside.say(format!(
"{error:#}\nThe harness settings stay as they are; rerun `thirdshift \
setup` once codex debug models works."
));
return Ok(None);
}
},
};
Ok(Some((harness, chosen)))
}
fn ask_claude(outside: &mut impl Outside, current: &ModelAndEffort) -> Result<ModelAndEffort> {
loop {
let chosen = ModelAndEffort {
model: ask_setting(outside, "Model", Harness::Claude, current.model.as_deref())?,
effort: ask_setting(
outside,
"Effort",
Harness::Claude,
current.effort.as_deref(),
)?,
};
if chosen.model.is_none() {
return Ok(chosen);
}
match outside.test_call(&chosen) {
Ok(()) => return Ok(chosen),
Err(error) => outside.say(format!("{error:#}")),
}
}
}
fn ask_codex(
outside: &mut impl Outside,
catalog: &harness::Catalog,
current: &ModelAndEffort,
) -> Result<ModelAndEffort> {
outside.say(format!("Codex's Models: {}", catalog.listed().join(", ")));
let model = loop {
let model = ask_setting(outside, "Model", Harness::Codex, current.model.as_deref())?;
match catalog.settle(model.as_deref(), None) {
Ok(settled) => break settled.model,
Err(error) => outside.say(format!("{error:#}")),
}
};
let efforts = catalog.efforts(model.as_deref()).join(", ");
outside.say(match &model {
Some(model) => format!("Efforts {model} supports: {efforts}"),
None => format!("Efforts Codex's Models support: {efforts}"),
});
loop {
let effort = ask_setting(outside, "Effort", Harness::Codex, current.effort.as_deref())?;
match catalog.settle(model.as_deref(), effort.as_deref()) {
Ok(settled) => return Ok(settled),
Err(error) => outside.say(format!("{error:#}")),
}
}
}
fn ask_setting(
outside: &mut impl Outside,
setting: &str,
harness: Harness,
current: Option<&str>,
) -> Result<Option<String>> {
let name = harness.name();
let own = format!("{name}'s own default");
let answer = match current {
Some(current) => answer(
outside,
&format!("{setting} for {name}, - for {own}"),
Some(current),
)?,
None => read(outside, &format!("{setting} for {name} [{own}]: "))?,
};
Ok(Some(answer).filter(|answer| !answer.is_empty() && answer != "-"))
}
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, 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, 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";