thirdshift 0.11.3

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Setup's questions, asked through [`Outside`]: on the terminal, on stderr,
//! with the answers read from stdin, so stdout stays empty. Each question's
//! default answer, taken by pressing Enter, is the current value. The Resend
//! API key is read hidden, and never shown.

use std::path::Path;

use anyhow::{Result, bail};

use super::{Outside, suggested_address};
use crate::config::{NotificationAddresses, UserConfig, UserConfigChanges};
use crate::email::DEFAULT_FROM;
use crate::harness::{Harness, ModelAndEffort};
use crate::resend_key::{self, Source};

/// What the user chose.
pub struct Answers {
    /// `harness.default`, and the Model and Effort of the Harness it names,
    /// each none for the Harness's own default; `None` when sessions can run
    /// on no Harness here, and the harness settings stay as they were.
    pub harness: Option<(Harness, ModelAndEffort)>,
    /// `merge.always`.
    pub merge_always: bool,
    /// `base.fix`: asked only with `merge.always` on, and otherwise its
    /// default, false.
    pub base_fix: bool,
    /// `launch.pull`.
    pub launch_pull: bool,
    /// `security.fix`: Security runs may fix reproduced findings.
    pub security_fix: bool,
    pub security_review: bool,
    /// With Run notifications on, `email.always`, their settings; with them
    /// off, `None`, and the email settings stay as they were.
    pub notifications: Option<Notifications>,
}

impl Answers {
    /// The User config changes, without Credentials or email effects.
    pub fn changes(&self) -> UserConfigChanges {
        UserConfigChanges {
            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(),
        }
    }

    /// Whether the user asked for a test email.
    pub fn send_test(&self) -> bool {
        self.notifications
            .as_ref()
            .is_some_and(|notifications| notifications.send_test)
    }

    /// The Resend API key the user gave, if any.
    pub fn key(&self) -> Option<&str> {
        self.notifications
            .as_ref()
            .and_then(|notifications| notifications.key.as_deref())
    }
}

/// The settings of Run notifications, when the user wants them.
pub struct Notifications {
    /// `email.to`.
    pub to: String,
    /// `email.from`.
    pub from: String,
    /// A Resend API key to save in the Credentials, if the user gave one.
    pub key: Option<String>,
    /// Send a test email once the User config is written.
    pub send_test: bool,
}

/// The first Run's offer of Setup, with no User config at `path`: whether
/// the user wants to set their defaults now, yes unless they say no.
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,
    )
}

/// Ask the Setup questions, with the settings in `current` as the default
/// answers, and the suggested address for `email.to` when `current` has
/// none. The Harness, Model and Effort come first, through one complete
/// Harness settings interaction. Base fixes are asked about only with every
/// Run a Merge run;
/// otherwise the answer is `base.fix`'s default, no.
/// The address is re-asked until it has an `@`. With Run notifications on,
/// the Resend API key is asked for too, unless `found`, where the key was
/// found, is `RESEND_API_KEY`, with Enter keeping the one in the
/// Credentials, if any. Ends in an error if stdin closes before the last
/// answer.
pub fn ask(
    outside: &mut impl Outside,
    current: &UserConfig,
    found: Option<Source>,
) -> Result<Answers> {
    let harness = outside.ask_harness_settings(&current.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,
    )?;
    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,
    )?;
    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,
            security_fix,
            security_review,
            notifications: None,
        });
    }
    let suggested = match &current.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,
        security_fix,
        security_review,
        notifications: Some(Notifications {
            to,
            from,
            key,
            send_test,
        }),
    })
}

/// Ask `question` until the answer is yes, no or nothing, which is `default`.
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),
            _ => {}
        }
    }
}

/// Ask `question` for a word, showing `default`, which nothing takes, if
/// there is one. With no default, nothing is asked again.
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) => {}
        }
    }
}

/// Ask for a Resend API key, hidden, until the answer is one or is nothing.
/// `on_enter` says, in the prompt, what nothing does; it is `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())
            }
        }
    }
}

/// Show `prompt` and read one answer, trimmed, or end Setup if stdin closes.
fn read(outside: &mut (impl Outside + ?Sized), prompt: &str) -> Result<String> {
    match outside.read(prompt)? {
        Some(line) => Ok(line),
        None => bail!(ENDED),
    }
}

/// Why Setup ends when stdin closes before the last answer.
const ENDED: &str = "Setup ended before its last answer; nothing written";