Skip to content
//! Interactive prompting layer.
//!
//! Eris can run in two modes (per its design): non-interactive/CI, where every
//! input comes from flags, args, and env vars and prompting is never used; and
//! interactive, where missing values are collected from the user via stdin.
//!
//! Whether prompting happens is decided by [`InputMode`]:
//! - `Never` โ€” never prompt (CI-safe); missing required values are errors.
//! - `Always` โ€” always prompt (forced via `-i`/`--interactive`).
//! - `Auto` โ€” prompt only when stdin is a terminal.
//!
//! The [`Prompter`] is generic over its reader and writer so it can be unit
//! tested with in-memory buffers.

use std::io::{self, BufRead, IsTerminal, Write};

/// How Eris should decide whether to prompt for missing input.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputMode {
    /// Prompt only when stdin is an interactive terminal.
    Auto,
    /// Always prompt, regardless of whether stdin is a terminal.
    Always,
    /// Never prompt; missing required values become errors.
    Never,
}

impl InputMode {
    /// Resolve whether prompting should occur, given whether stdin is a TTY.
    #[must_use]
    pub fn is_interactive(self, stdin_is_tty: bool) -> bool {
        match self {
            InputMode::Always => true,
            InputMode::Never => false,
            InputMode::Auto => stdin_is_tty,
        }
    }
}

/// Collects input from a reader while writing prompts to a writer.
///
/// When `interactive` is `false`, [`Prompter::require`] never reads input and
/// instead uses the supplied default (or errors if there is none).
pub struct Prompter<R: BufRead, W: Write> {
    reader: R,
    writer: W,
    interactive: bool,
}

impl<R: BufRead, W: Write> Prompter<R, W> {
    /// Create a new prompter over the given reader/writer.
    pub fn new(reader: R, writer: W, interactive: bool) -> Self {
        Self {
            reader,
            writer,
            interactive,
        }
    }

    /// Whether this prompter will actually prompt for input.
    #[must_use]
    pub fn interactive(&self) -> bool {
        self.interactive
    }

    /// Resolve a required value.
    ///
    /// - Non-interactive: return `default` if present, else an error.
    /// - Interactive: prompt (showing `default` if any). An empty response uses
    ///   the default when one exists, otherwise the user is re-prompted. On EOF,
    ///   the default is used if present, else an error is returned.
    pub fn require(&mut self, label: &str, default: Option<&str>) -> io::Result<String> {
        if !self.interactive {
            return default.map(str::to_string).ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::InvalidInput,
                    format!("missing required value: {label}"),
                )
            });
        }

        loop {
            match default {
                Some(d) => write!(self.writer, "{label} [{d}]: ")?,
                None => write!(self.writer, "{label}: ")?,
            }
            self.writer.flush()?;

            let mut line = String::new();
            let read = self.reader.read_line(&mut line)?;
            if read == 0 {
                // EOF: fall back to default or fail.
                return default.map(str::to_string).ok_or_else(|| {
                    io::Error::new(
                        io::ErrorKind::UnexpectedEof,
                        format!("no input provided for: {label}"),
                    )
                });
            }

            let trimmed = line.trim();
            if trimmed.is_empty() {
                if let Some(d) = default {
                    return Ok(d.to_string());
                }
                writeln!(self.writer, "  (a value is required)")?;
                continue;
            }

            return Ok(trimmed.to_string());
        }
    }
}

/// Build a [`Prompter`] wired to real stdin (for reading) and stderr (for
/// prompts, so stdout stays clean for machine-consumable output).
///
/// The `mode` and stdin's TTY status together decide whether prompting occurs.
#[must_use]
pub fn stdio(mode: InputMode) -> Prompter<io::BufReader<io::Stdin>, io::Stderr> {
    let interactive = mode.is_interactive(io::stdin().is_terminal());
    Prompter::new(io::BufReader::new(io::stdin()), io::stderr(), interactive)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    fn prompter(input: &str, interactive: bool) -> Prompter<Cursor<Vec<u8>>, Vec<u8>> {
        Prompter::new(
            Cursor::new(input.as_bytes().to_vec()),
            Vec::new(),
            interactive,
        )
    }

    #[test]
    fn input_mode_resolution() {
        assert!(InputMode::Always.is_interactive(false));
        assert!(!InputMode::Never.is_interactive(true));
        assert!(InputMode::Auto.is_interactive(true));
        assert!(!InputMode::Auto.is_interactive(false));
    }

    #[test]
    fn non_interactive_uses_default() {
        let mut p = prompter("", false);
        assert_eq!(p.require("from", Some("HEAD")).unwrap(), "HEAD");
    }

    #[test]
    fn non_interactive_missing_default_errors() {
        let mut p = prompter("", false);
        let err = p.require("from", None).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    }

    #[test]
    fn interactive_reads_value() {
        let mut p = prompter("v1.2.0\n", true);
        assert_eq!(p.require("from", None).unwrap(), "v1.2.0");
    }

    #[test]
    fn interactive_trims_whitespace() {
        let mut p = prompter("  main  \n", true);
        assert_eq!(p.require("to", None).unwrap(), "main");
    }

    #[test]
    fn interactive_empty_uses_default() {
        let mut p = prompter("\n", true);
        assert_eq!(p.require("to", Some("HEAD")).unwrap(), "HEAD");
    }

    #[test]
    fn interactive_empty_then_value_when_required() {
        // First line empty (re-prompt), second line provides the value.
        let mut p = prompter("\nrelease\n", true);
        assert_eq!(p.require("from", None).unwrap(), "release");
    }

    #[test]
    fn interactive_eof_without_default_errors() {
        let mut p = prompter("", true);
        let err = p.require("from", None).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
    }

    #[test]
    fn interactive_eof_with_default_uses_default() {
        let mut p = prompter("", true);
        assert_eq!(p.require("to", Some("HEAD")).unwrap(), "HEAD");
    }

    #[test]
    fn prompt_text_includes_default_hint() {
        let mut p = prompter("\n", true);
        let _ = p.require("to", Some("HEAD")).unwrap();
        let written = String::from_utf8(p.writer).unwrap();
        assert!(written.contains("to [HEAD]:"));
    }
}